当前位置: 首页 > news >正文

用类型系统驯服LLM:让模型输出可编程、可验证

我在做客服工单分类时遇到过一件很折磨人的事。第一次调用大模型,让它从用户反馈里抽出几个字段,很快就跑通了。可一放到真实数据里,输出就开始“表演”:有时把 JSON 包在 markdown 代码块里,有时多返回一个字段,有时把布尔值写成 “yes”,偶尔还认真写了一段解释,根本没法直接解析。当时同事建议我再改改提示词,我改了一下午,提示词越来越长,结果反而更不稳定。后来我换了一个思路:不再把“让模型输出正确”当作第一目标,而是先为模型的输入输出定义一套类型。回头看,这正是标题 “Types with AI: Working with LLMs Through Types” 想表达的核心理念——用类型系统和大模型协作,把不可控的生成过程,变成可编程、可验证、可维护的工程流程。

这个方向看起来不像“提示词工程”那样容易感知,但它正在改变 LLM 应用从“能跑”到“能上线”之间的关键差距。下面我会拆开讲两件事:这类思路到底解决了什么问题,以及当你真正把它放进项目时,要注意哪些边界和坑。

1. 为什么要在 LLM 调用里引入“类型”这个概念

1.1 LLM 输出的本质是字符串,而开发需要的是结构化数据

先回到最基础的观察。无论调用哪家模型,只要走普通文本接口,你拿到的本质上都是一串 token,也就是字符串。即使你要求模型“返回 JSON”,它也只会先输出一个 JSON 字符串,然后由客户端去JSON.parsemodel_validate_json处理。

问题在于,传统 API 的响应是有契约的。后端返回一个对象,前端可以依赖字段名、类型和结构。但 LLM 的文本接口没有内置契约。它可能遵守 JSON 格式,也可能不遵守;可能一次性给出正确结构,也可能在字段名里多加一个空格。于是开发者在拿到输出后,不得不做大量防御式解析:

  • 先判断是不是 JSON;
  • 再判断有没有被代码块包裹;
  • 再判断字段是否存在;
  • 再判断类型是否正确;
  • 再判断有没有多出意外字段。

这些代码写起来不难,但很碎,而且每遇到一个新场景就要重写一遍。更麻烦的是,这类解析逻辑通常不会出现在单元测试覆盖里,因为样本一变,解析失败方式也变。类型系统的价值在这里才开始显现:它允许你把“模型输出应该长什么样”这份隐式期待,变成一份显式定义。开发者不再靠人脑记忆,而是靠代码来表达期望。

1.2 类型不只是校验,更是生成流程的契约

很多人听到“类型”第一反应是运行时校验。的确,拿zodpydantic对模型输出做parse是直接的一步。但类型的作用不止发生在解析那一刻,它更应该在写提示词之前就参与设计。

如果你已经定义了一个Ticket类型,字段是categoryurgencydemandsRefund,那么你在写提示词时,可以直接把这份字段清单或 JSON Schema 交给模型。模型看到的不再是模糊的“请返回以下信息”,而是一份明确的结构说明。它能因此减少对字段名、枚举值、嵌套关系的猜测。

这很像前后端对接时的接口文档。传统开发里,后端定义好 OpenAPI spec,前端按 spec 生成客户端代码。现在与 LLM 协作,类型定义就扮演了 spec 的角色。给模型的提示词是“用户需求说明”,类型定义是“接口契约”。两者结合,模型才知道该怎么把自由生成的文本,装进业务系统需要的数据结构里。

1.3 从“结果校验”到“过程约束”的转变

再往前走一步,类型约束不应该只发生在最终输出阶段。尤其在 LLM Agent 场景,模型需要主动调用工具,而工具函数的参数本身就是强类型约束。

举个例子:一个 Agent 需要调用searchOrder工具,这个工具的参数可能是{ orderId: string, country?: string }。模型生成的参数不能直接丢给真实函数执行,因为模型可能把country写成一个对象,也可能把必填的orderId丢成空字符串。如果先按类型 Schema 校验一次,非法参数会被拦截,而不是带着脏数据进入业务系统。

这也是目前“llm agent”开发里一个非常稳定的落地点。过去我们更关注模型能不能读懂用户意图,却忽略了给模型一套“允许操作什么、参数必须长什么样”的安全边界。类型系统正好提供了这一层边界。它不负责让模型变得更聪明,但负责让模型的动作不越界。

所以我理解到的核心判断是:用类型和 LLM 协作,真正解决的并不是“让模型回答得更准”,而是“让不可控的生成变得可编程、可验证、可维护”。

2. 从“写死提示词”到“类型驱动的 LLM 工作流”

2.1 最小流程:用 Schema 定义输入和输出结构

如果你想实际尝试,不必引入复杂框架。一个最小流程只需要三样东西:一个 Schema 校验库、一段把 Schema 注入提示词的逻辑、一个在拿到模型输出后执行的解析函数。

下面是一个 TypeScript + zod 的示例结构:

import { z } from 'zod'; const TicketSchema = z.object({ category: z.enum(['refund', 'shipping', 'account']), urgency: z.enum(['low', 'medium', 'high']), demandsRefund: z.boolean(), }); type Ticket = z.infer<typeof TicketSchema>;

然后你可以把JSON.stringify(TicketSchema.shape)或一个写好的 JSON Schema 描述放进提示词里。最后模型返回文本,执行:

const parsed = TicketSchema.parse(rawText.replace(/```json|```/g, ''));

Python 生态里,类似的结构通常用 pydantic 表达:

from pydantic import BaseModel from typing import Literal class Ticket(BaseModel): category: Literal['refund', 'shipping', 'account'] urgency: Literal['low', 'medium', 'high'] demands_refund: bool

然后在拿到模型输出后:

parsed = Ticket.model_validate_json(raw_text)

注意:这只是一个通用示例。具体模型接口返回的是纯文本,还是已经剥离代码块,不同 SDK 处理方式不一样。落地前要先确认依赖版本和实际返回格式。

2.2 类型如何同时服务模型、IDE 和运行时校验

类型定义在 LLM 调用流程里的价值,是同时作用于三端的。

对模型来说,Schema 提供了一份精确的字段清单。很多模型在直接看 JSON Schema 时的表现,会比看一段语义含糊的“请返回如下字段”更稳定,因为模型不需要猜测字段类型和可选项是什么。这是我把类型定义放进提示词后的实际体感。

对开发者来说,类型定义让 IDE 能自动补全。你在业务代码里使用解析出来的Ticket对象时,编辑器能准确提示字段名和类型。如果以后把字段从demandsRefund改成requestRefund,编译器会在所有引用处给出报错,而不会再出现“找到不到字段”的运行时错误。

对运行时来说,解析结果已经是一份强类型对象。下游的统计、存储、展示逻辑都不需要再手写一堆if (data.category !== undefined)的防御代码。只要校验通过,字段就存在,类型就正确。这能省下大量重复的防御式解析。

2.3 流式输出与工具调用里,类型依然能兜底

很多实际应用不满足于一次性返回,而是采用流式输出,让用户看到逐字生成的效果。此时类型依然可以介入,只是方式要调整。

一个常见做法是维护一个文本缓冲区,当缓冲区里的内容足够完整时,尝试用 Schema 做增量解析。可能前几个 chunk 还不够解析,但一旦到了某个边界,字段已经能解析出来,就可以提前进入后续处理。这种方式不会替代最终解析,但它能让决策更早发生。

工具调用场景更直接。当前很多平台支持 function calling,模型会输出结构化的工具调用参数。但这些参数同样可能不合法。比如模型应该在country字段里放国家代码,却传入了完整国家名;或者把可选字段都填了空字符串。对这些参数执行一次 Schema 校验,是 Agent 工程里成本很低但收益很高的一步。

from pydantic import BaseModel class SearchOrderArgs(BaseModel): order_id: str country: str | None = None

拿到模型的raw_arguments后,直接做SearchOrderArgs.model_validate(json.loads(raw_arguments))。如果这一步失败,就不要调用真实工具,先尝试修正参数或要求模型重新生成。

2.4 从“单次调用”到“批量化”:先定义好输入边界

另一个容易忽略的点是:类型不仅约束输出,也应该约束输入。尤其是做批量任务时,输入数据本身就可能很脏。比如一个字段本应是字符串,结果调用方传入了嵌套对象,直接拼进 prompt 后就变成了[object Object]。这个问题很难通过“加强提示词”解决。

我一般会在批量处理前,先给输入定义一个基础 Schema。这样既能在源头拦截脏数据,也方便记录失败样本。单次任务跑通,只能说明流程没有断;批量任务稳定,才说明边界定义已经足够清楚。

不要一上来就把批量数和并发数拉满。先用一条样例确认输入、输出和日志都正常,再逐渐扩大规模。类型校验不是保险箱,但它能帮你更早发现哪一层出了问题。

3. 落地这套思路时最容易踩的坑

3.1 场景边界:结构化结果加约束没问题,开放写作别硬套

类型约束最适合的场景是信息抽取、分类打标、表单填充、工具调用参数、Agent 动作编排。这些任务共同点是:业务系统需要结构化数据,模型只是负责把非结构化输入转换成结构化结果。

但如果是写营销文案、创意故事、自由对话,类型约束就不应该过多介入。你不能要求一首诗必须返回{ title: string, content: string, emotion: 'happy' },还把情感也强构成枚举值。创作类任务需要的是表达空间,硬套类型只会让生成结果变得干瘪。

如果确实需要在创作场景里保留一点约束,可以只在最外层做轻量封装,例如只限定{ title: string, content: string },内容字段本身还是自由文本。这样就兼顾了流程可控与内容开放。

不要把类型约束当成万能钥匙。它更适合当一个输出网关,而不是创作编辑器。

3.2 字段设计过细会诱发幻觉和缺失

类型定义不是越细越好。我见过有人把客服系统中一个工单对象定义成 30 个字段,要求模型一次全部返回。结果模型面对大量必须填写的字段时,开始自己编内容:未知的客户类型被硬塞进一个枚举,缺失的订单号被补成一个相似但不存在的值。这其实就是幻觉的一种表现,而根因是类型结构超出了模型单次输出的合理承载量。

从经验看,单次输出的结构化字段控制在 5 到 10 个以内会稳定很多。嵌套层级尽量别太深,尽量把枚举值写得明确,并为每个字段提供一个示例值。如果业务对象确实很复杂,不要试图让模型一次生成完整对象,可以把任务拆成几轮:先抽取摘要,再抽取实体,再填充关系。每一次都只面对一个小而明确的 Schema。

3.3 模型升级后的类型漂移

类型定义不会一劳永逸。模型版本升级后,推理能力、格式遵循能力、字段偏好都可能发生变化。原来 90% 能通过校验的 Schema,换到新版模型后可能下降到 70%。这不是 Schema 写错了,而是模型分布变了。

处理这类问题要靠回归测试。准备一组固定样本,不频繁改动,每次模型升级后跑一遍,记录字段缺失率、枚举非法率、总解析失败率。只有这样,你才能区分“这一次模型输出质量波动”和“长期趋势下降”。

类型定义应该像代码一样被管理。变更时要有记录,上线前要有测试。不要让一个 Schema 在模型升级、提示词改版后悄悄失去了约束力。

3.4 类型解析失败时的排查链路

遇到解析失败,第一反应不要改成“再试一次”或“换个说法”。先按顺序排查下面几层:

  1. 拿到原始输出。先看模型到底返回了什么,是 JSON、markdown 代码块,还是一段解释文本。
  2. 检查 Schema 与提示词是否一致。字段名是否拼写一致,枚举值是否和示例一致,有没有把布尔值描述成字符串。
  3. 检查生成参数。temperature 是不是太高,是否开启了 JSON mode,有没有要求模型“不要输出解释”。
  4. 检查截断。如果 max_tokens 设置太小,输出的 JSON 可能被切断,缺少右括号或字段。
  5. 最后再考虑重试或换模型。只有前四层都确认无误,重试才有意义。
失败现象常见原因处理方式
输出被 markdown 包裹模型默认生成 markdown 文本解析前剥除代码块标记,或在提示词中明确只输出 JSON
字段名对不上提示词与 Schema 描述不一致把 Schema 作为唯一字段来源,避免两套描述
枚举值不合法模型自创了一个不在列表里的值为每个枚举提供示例,并让 Schema 明确列出可选项
JSON 不完整输出超过 max_tokens调大 token 上限或缩短输入上下文
重复解析失败Schema 过于复杂拆分成多个子任务,每个任务只做一小段输出

4. 类型优先的接入方法论:从单次调用到工程化

4.1 一个类型优先的四步接入框架

如果你要把这套思路带进真实项目,我建议按四步走,顺序不要跳。

第一步:定义类型边界。先写出期望返回的字段、类型、可枚举值。就像设计接口一样,考虑哪些是必填,哪些是可选,哪些值是业务可以接受的默认值。这一步不写任何提示词。

第二步:设计提示词与示例。把 Schema 或字段说明注入提示词,同时提供一到两个 few-shot 示例。示例不应该只展示理想输出,还应该展示边界情况,例如缺失字段时应返回什么。

第三步:小样本回归验证。挑 20 到 50 条有代表性的真实样本,不要挑太干净的,跑一轮,统计失败率。如果失败率过高,先回去修 Schema 和示例,不要急着加“再来一次”的重试。

第四步:监控与迭代。上线后记录每个样本的原始输出、解析结果、校验错误类型。定期抽样,观察类型覆盖率和字段非法率。模型升级或提示词改动后,重新跑一遍回归集。

4.2 如何定义错误分类与重试策略

没有类型校验时,一个异常只能笼统地叫“解析失败”。有类型校验后,你可以把错误拆成更细的类别,并针对每一类设计不同处理策略:

  • SchemaValidationError:通常是模型输出结构不合法,盲目重试收益不高,应该先看原始输出,再判断是 Schema 问题还是模型问题。
  • TimeoutError / 网络错误:这类错误和模型输出无关,可以使用指数退避重试。
  • ContextWindowError:输入太长导致请求超过上下文窗口,重试无意义,应该缩短输入或做摘要。
  • ContentFilterError:内容被安全策略拦截,需要调整提示词或检查输入是否触发了敏感规则。

我习惯把每类错误对应到自己的处理函数,而不是用一个大的try/catch吃掉所有异常。这样当线上数据反馈回来时,你可以快速知道是哪一类问题占多数,而不是只在错误日志里看到一堆堆栈。

4.3 团队协作中类型约定的价值

类型定义对团队协作的影响,往往比个人使用更容易被低估。当多个角色都依赖同一个 LLM 调用时,类型定义就是大家共同的接口契约。

后端可以按类型定义准备存储字段;前端可以按类型定义做展示;测试可以按类型定义准备断言。如果类型变更了,代码评审时一眼就能看到影响范围。而如果每个人各写各的提示词,字段名靠默契沟通,最后一定会有人踩到“模型返回的是user_name,但页面读的是username”这样的坑。

这也是我把“类型”和“AI 协作”放在一起理解的原因:它不是给模型看的魔法,而是让整个团队面对模型输出时,仍然能像做传统 API 一样稳定协作的工具。

4.4 长期监控“类型覆盖”而不是只看生成结果

很多团队上线 LLM 功能后,只关注“回答质量高不高”“用户满意度怎么样”,却没有把结构稳定性纳入监控指标。事实上,对结构化抽取类应用来说,一个更客观的指标是类型通过率,也就是所有请求里,能通过 Schema 校验的占比。

我会在日志里记录每次调用的原始输出、解析结果、校验错误类型。全量记录可能成本高,那就抽样。当发现类型通过率突然下降时,优先检查三件事:模型版本是否变了、输入数据分布是否变了、Schema 是否最近被改过。

这个指标比“看起来差不差”要可靠得多。因为它不依赖人来做主观判断,只看结构是否符合约定。模型能力再强,如果输出拿不到业务系统里,价值就为零。

5. 对“AI 编程”和 Agent 开发来说,这个方向的边界在哪里

5.1 什么场景值得用,什么场景不值得用

值得用类型约束的场景,通常会有一个共同点:输出需要被程序继续消费。比如自动化工单分类、信息抽取、结构化报表、表单自动填写、Agent 的工具调用参数。这类场景如果不用类型约束,下游程序就必须处理大量非预期输入,开发成本和稳定性会严重受损。

不值得用类型约束的场景,则通常是生成结果直接面向人、不需要被程序严格解析的。比如开放式的故事生成、营销创意脑暴、闲聊对话、诗歌创作。这类场景强行加 Schema,只会限制模型的表达能力,增加生成失败的概率。

当然也存在混合模式:产品需要一段“有感染力的文案”作为最终呈现,但标题字段、目标用户、推广渠道等元信息仍然需要结构化。这种情况下,最外层的自由文本交给模型发挥,外围的元数据结构交给类型约束。

适合类型约束不适合硬套类型
信息抽取开放式写作
分类打标创意文案
表单填充自由对话
工具调用参数情感陪伴
Agent 中间状态多轮无关主题闲聊

5.2 这个方向不会替代提示词工程,但它把工程的地基修好了

有一种担心是,如果都靠类型约束了,提示词还要不要写?当然要写。类型负责的是结构边界,提示词负责的是表达和上下文。两者是分层关系。

一个合适的比喻是:提示词是给模型的“产品说明书”,类型定义是给系统的“接口契约”。产品说明书要写得清楚,模型才知道客户需求是什么;接口契约要定得严格,程序才能安全地处理模型输出。没有前者,模型理解不对;没有后者,系统稳定不住。

可以预见的是,随着 LLM 应用逐渐进入生产环境,开发方式会越来越像常规后端开发。定义接口、mock 数据、单元测试、灰度发布、监控告警,这些原本属于软件工程的流程,会在 LLM 应用里变得越来越重要。类型驱动只是其中最早能落地的一环。

5.3 给正在入门的开发者的一个落地顺序

如果你刚接触这个方向,我建议不要一上来就搭一套 Agent 框架。更稳的路径是:

  • 第一步:找一个非常简单的字段抽取任务,例如从一段快递反馈中抽取“是否拒收”和“客户情绪”。
  • 第二步:用顺手的数据校验库定义三到五个字段,先不用管复杂嵌套。
  • 第三步:把 Schema 注入提示词,拿到输出后立刻做解析校验。
  • 第四步:找 50 条真实样本,统计一下第一次解析成功率。大概率你会看到不少失败,这是正常现象。
  • 第五步:根据失败类型回改 Schema,让它更贴近模型实际的生成偏好。
  • 第六步:确认稳定后,再考虑接入工具调用、流式输出和批量处理。

不要急着把这个方法用到所有功能上。在一个小任务上建立完整闭环,比在十个任务上各跑一遍更有价值。因为类型驱动的工作流,最大收益不是第一次跑通,而是后期维护时少踩很多坑。只要流程闭环了,后续扩大范围只是复制方法论的问题。

如果你正准备把某个 LLM 功能从脚本搬进正式业务,我的建议很简单:先别急着写 prompt,先打开一个空白文件,定义第一组类型。把输入和输出边界说清楚。再让模型去填空。你会发现,很多看起来像玄学的问题,最后都会被拆成可以定位、可以修、可以回归验证的普通工程问题。

http://www.cnnetsun.cn/news/4222952.html

相关文章:

  • 日本大学院笔试备考:线性代数与数据结构高效练习法
  • QClaw低代码平台在智慧航道业务流程自动化中的实战应用
  • PyTorch Java神经网络部署:从模型导出到生产级服务构建
  • RDM与Art-Net协议实战:从协议解析到灯光调试工具开发
  • SystemVerilog数组三大类型:packed/unpacked/队列的本质与验证选型指南
  • 大模型Agent可观测性实践:从黑盒炼丹到白盒炼钢
  • CCPD2019光照子集:5000张暗亮车牌图与YOLO训练实战
  • AI时代技术债管理:从代码生成到工程纪律的实战指南
  • 三款编程Agent横评:Copilot、Cursor与Claude Code选型指南
  • 软件测试面试宝典:结构化知识与实战技巧
  • 湿法后道清洗:药液配方与设备协同,攻克半导体制造洁净度最后一关
  • 向量数据库双索引架构实战:HNSW与Payload协同优化海量语义搜索
  • 西门子S7-200 SMART数据存取区与数据类型详解:编程基石与实战应用
  • 车牌检测数据集从解压到YOLOv8训练全流程避坑指南
  • 从零开始SKILL开发:Cadence Virtuoso自动化脚本实战指南
  • 多Agent系统架构设计:从单体智能到群体协作的工程实践
  • 基于PIC32的单片机游戏机开发实战
  • 基于YOLOv8的手语识别系统实战:从数据标注到部署
  • 图论算法精解:Dijkstra、Kruskal、最大流与匈牙利算法建模实战
  • STM32裸机方波驱动:蜂鸣器/马达/风扇的硬件级实现
  • OpenClaw智能体进化停滞?五大核心症结与高阶调优实战指南
  • B760M+i5-14400安装Ubuntu 24.04全流程:BIOS设置与常见问题解决
  • ESP-NOW实战进阶:双向通信、可靠性与低功耗节点设计
  • 从网页到PDF:高质量打印件生成全攻略与工具实践
  • 足式机器人高速奔跑训练:从仿真到实物的强化学习控制
  • 数学建模竞赛实战指南:从模型选型到论文写作的完整方法论
  • Jupyter Notebook生成式AI开发调试环境配置指南
  • AI编程助手实战:从提示词到工作流,一周效率倍增全记录
  • mid360+FAST-LIO2部署实战:从驱动编译到SLAM建图全流程
  • 数学建模B题破题核心:GPS轨迹清洗与碳排放动态建模