1. 为什么“看起来像 JSON”是 Agent 工程里最隐蔽的坑
做 Agent 开发的人,几乎都经历过这样一个阶段:模型在对话框里输出了一段文本,肉眼一看,妥妥的 JSON,花括号、引号、逗号一个不少,你满心欢喜地把这段字符串丢给JSON.parse,结果程序直接抛异常。你盯着屏幕反复看,明明长得一模一样,为什么解析不了?答案往往藏在你看不见的地方——可能是一个中文全角引号,可能是末尾多了一个逗号,可能是模型在 JSON 前面加了一句“好的,以下是结果:”,也可能是它把数字写成了字符串,把null写成了None。
这个问题在单轮对话里只是个小麻烦,但在 Agent 系统里是致命的。Agent 的核心运转逻辑是“模型输出 → 程序解析 → 执行动作 → 结果回灌 → 模型再输出”,这个循环里只要有一环的解析挂了,整个链路就断了。更麻烦的是,这类错误往往不是必现的,同一个 prompt,跑十次可能八次正常,两次抽风,测试环境好好的,上线之后偶发崩溃,排查起来极其痛苦。
我见过太多项目,前期 demo 跑得飞起,一到生产环境就各种“Agent execution terminated due to error”,追根溯源,十有八九是结构化输出没做工程化约束。模型本身不是数据库,它输出的是概率分布下的 token 序列,不是确定性的序列化结果。你指望它每次都吐出严格合法的 JSON,就像指望一个即兴演讲的人每次都按 PPT 逐字念稿,不现实。
所以这篇内容想聊的核心就一件事:怎么把 Agent 的结构化输出从“看起来像 JSON 的自由文本”变成“程序可以无条件信任的数据契约”。这不是一个 prompt 技巧问题,而是一套工程体系问题,涉及 Schema 设计、约束策略、解析容错、校验重试、可观测性等多个层面。适合正在做 Agent 项目、被解析问题折磨过的开发者,也适合刚开始接触 Agent 架构、想少走弯路的朋友。下面我会按我实际项目里踩过的坑和总结的方案,一层层拆开讲。
2. 结构化输出的本质:从“求模型配合”到“用工程约束”
2.1 自由文本和结构化数据的根本矛盾
要理解这个问题,得先搞清楚模型到底在干什么。大模型的输出本质是下一个 token 的概率预测,它生成的内容是“最像人话的续写”,而不是“符合某个数据格式的序列化结果”。当你要求它输出 JSON 时,它是在模仿它训练数据里见过的 JSON 的样子,而不是在执行一个序列化函数。
这两者的区别很关键。序列化函数是确定性的:给定一个对象,输出的字符串一定合法。而模型是概率性的:它知道 JSON 大概长什么样,但不知道你具体要什么约束,也不知道哪个字段是必填、哪个字段是枚举、哪个字段是数字。它只能根据你的描述去“猜”,猜对了是运气,猜错了是常态。
我早期做的一个项目,让模型从用户消息里抽取订单信息,输出{"order_id": "...", "amount": ..., "status": "..."}。测试的时候一切正常,上线之后发现,模型有时候会把amount输出成"一百二十元"这种自然语言,有时候status会输出成"已支付(待确认)"这种带括号的复合值。程序按数字解析amount直接崩,按枚举匹配status匹配不上。这就是典型的“模型理解的是语义,程序需要的是格式”,两者之间存在天然的鸿沟。
2.2 三种约束层级的取舍
解决这个矛盾,业界大致有三条路线,约束强度从弱到强:
| 约束层级 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Prompt 约束 | 在提示词里写清楚格式要求,给示例 | 实现简单,零成本 | 不可靠,长上下文易漂移 | 原型验证、低精度要求 |
| 解码约束 | 用语法约束解码,限制 token 生成范围 | 输出必然合法 | 需要模型/框架支持,可能影响生成质量 | 对格式要求极高的场景 |
| 后处理校验 | 解析 + Schema 校验 + 失败重试 | 通用,兼容任意模型 | 需要额外重试成本 | 生产环境主流方案 |
我个人的经验是:生产环境不要只依赖任何单一层级。Prompt 约束是基础,必须写好;解码约束如果框架支持就开;后处理校验是兜底,必须有。三层叠加,才能把失败率压到可接受的范围。
这里要特别说一句,很多人对“解码约束”有误解,以为开了 grammar 约束就万事大吉。实际上约束解码只能保证语法合法,不能保证语义正确。比如你约束了status必须是字符串,模型完全可以输出"status": "随便什么字符串",语法没问题,但业务上没意义。所以语法约束和语义校验是两回事,不能互相替代。
2.3 为什么 JSON Schema 是绕不开的中间层
在所有这些方案里,JSON Schema是那个把“模型意图”和“程序契约”连接起来的关键中间层。它用一套标准化的描述语言,把“我要什么结构、什么类型、什么约束”表达清楚,既能喂给模型当指令,又能喂给校验器当规则,还能喂给下游当文档。
我见过一些团队图省事,不用 Schema,直接在 prompt 里用自然语言描述字段,然后在代码里手写 if-else 校验。这种做法在字段少的时候还行,字段一多就是灾难:prompt 和校验逻辑两处维护,改一个字段要改两个地方,还容易不一致。用 JSON Schema 统一描述,prompt 生成和校验都从同一份 Schema 派生,改一处全生效,这是工程化的基本要求。
而且 JSON Schema 生态成熟,Python 有jsonschema,JavaScript 有ajv,各种语言都有对应实现,校验能力覆盖类型、必填、枚举、范围、正则、嵌套结构等,足够应付绝大多数 Agent 场景。下面我会具体讲怎么设计一份好用的 Schema。
3. JSON Schema 设计:让模型“没机会”输出错误格式
3.1 字段设计的三条铁律
设计给 Agent 用的 Schema,和设计给 API 用的 Schema,思路不太一样。API 的 Schema 是给人看的契约,Agent 的 Schema 还要兼顾“模型能不能理解、能不能稳定生成”。我总结了三条铁律:
第一条:能用枚举就别用自由字符串。这是降低解析失败率最有效的一招。比如订单状态,不要写"type": "string",要写"enum": ["pending", "paid", "shipped", "completed", "cancelled"]。模型在枚举约束下,输出偏离的概率大幅下降,因为它知道只能从这几个里选。我实测过,把状态字段从自由字符串改成枚举,解析失败率能降一个数量级。
第二条:嵌套层级能浅就浅。模型对深层嵌套结构的生成稳定性明显下降。三层以上的嵌套,出错概率陡增。如果业务允许,尽量把嵌套结构拍平成扁平结构,用字段名前缀区分。比如{"user": {"name": "...", "age": ...}}可以拍成{"user_name": "...", "user_age": ...}。扁平结构不仅模型好生成,解析和校验也简单。
第三条:必填字段要少而精。每个必填字段都是一次失败机会。如果某个字段模型经常漏,要么把它改成可选,要么在 prompt 里重点强调。我一般会把字段分成“核心必填”和“尽力而为”两类,核心字段少而稳定,其余字段允许缺失,程序做默认值处理。
3.2 一份可直接抄的 Schema 模板
下面这份 Schema 是我在多个项目里沉淀下来的模板,覆盖了常见的约束类型,你可以直接改字段名用:
{ "type": "object", "properties": { "intent": { "type": "string", "enum": ["query", "create", "update", "delete", "unknown"], "description": "用户意图分类" }, "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "意图置信度,0到1之间" }, "entities": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"], "additionalProperties": false }, "description": "抽取的实体列表" }, "raw_query": { "type": "string", "description": "用户原始输入" } }, "required": ["intent", "confidence"], "additionalProperties": false }几个关键点解释一下。additionalProperties: false很重要,它禁止模型输出 Schema 里没定义的字段,避免模型“自作主张”加字段导致下游解析出意外数据。enum和minimum/maximum是硬约束,能挡掉大量脏数据。description字段别省,它是给模型看的说明,写清楚了模型理解更准。
3.3 把 Schema 喂给模型的正确姿势
Schema 设计好了,怎么喂给模型也有讲究。我见过两种常见错误:一种是把 Schema 原样 JSON 字符串塞进 prompt,模型看着一堆花括号容易懵;另一种是只用自然语言描述,不给结构,模型自由发挥。
我的做法是双管齐下:既给 JSON Schema 原文(让模型知道精确结构),又给一个填充好的示例(让模型知道长什么样)。示例的力量比纯 Schema 大得多,模型是模仿学习的高手,给它一个正确的样例,它照着填的准确率远高于只看 Schema。
prompt 里我会这样组织:
你需要从用户输入中抽取结构化信息,严格按照以下 JSON Schema 输出,不要输出任何额外文字。 Schema: {上面那份 schema} 示例输入:帮我查一下订单 12345 的状态 示例输出:{"intent": "query", "confidence": 0.95, "entities": [{"key": "order_id", "value": "12345"}], "raw_query": "帮我查一下订单 12345 的状态"} 现在请处理:{用户输入}注意最后那句“不要输出任何额外文字”,以及示例输出里没有任何 markdown 代码块包裹。这两个细节能显著减少模型加前后缀的概率。很多人喜欢在示例里用 ```json 包裹,结果模型也跟着输出代码块,解析前还得先剥壳,多此一举。
4. 解析容错:当模型还是“不听话”时怎么办
4.1 解析失败的四大类型
就算 Schema 设计得再好,prompt 写得再细,模型还是会有一定概率输出不合规的内容。我把实际遇到的解析失败归成四类,每类的处理策略不同:
类型一:包裹污染。模型在 JSON 外面加了说明文字或代码块标记,比如“好的,结果如下:{...}”或者json {...}。这类最好处理,用正则把 JSON 主体抠出来就行。
类型二:语法瑕疵。JSON 本身有语法错误,比如末尾多逗号、用了单引号、键没加引号、中文全角符号。这类需要做语法修复,简单的替换能解决大部分,复杂的得上容错解析器。
类型三:类型错位。语法合法但类型不对,比如该是数字的给了字符串,该是数组的给了单个对象。这类靠 Schema 校验能发现,但修复需要业务逻辑介入。
类型四:语义偏离。格式完全合法,但内容不对,比如枚举值给了个没定义的,必填字段给了空字符串。这类最隐蔽,必须靠 Schema 校验兜底。
4.2 一个能扛住大部分脏数据的解析函数
下面这个解析函数是我项目里一直在用的,思路是“先清洗、再解析、后校验”,层层递进:
import json import re from jsonschema import validate, ValidationError def extract_json_block(text: str) -> str: """从自由文本中抠出最外层的 JSON 块""" # 去掉 markdown 代码块标记 text = re.sub(r"```(?:json)?", "", text).strip() # 找到第一个 { 或 [ 到最后一个 } 或 ] start = min( (text.find(c) for c in "{[" if text.find(c) != -1), default=-1 ) end = max(text.rfind(c) for c in "}]") if start == -1 or end == -1 or end < start: raise ValueError("未找到 JSON 主体") return text[start:end + 1] def repair_json(text: str) -> str: """常见语法瑕疵修复""" # 全角引号转半角 text = text.replace("“", '"').replace("”", '"') text = text.replace("‘", "'").replace("’", "'") # 去掉对象/数组末尾的多余逗号 text = re.sub(r",\s*([}\]])", r"\1", text) # 单引号键名转双引号(简单场景) text = re.sub(r"([{,]\s*)'([^']+)'(\s*:)", r'\1"\2"\3', text) return text def parse_agent_output(text: str, schema: dict) -> dict: """完整的解析流程:清洗 -> 修复 -> 解析 -> 校验""" block = extract_json_block(text) try: data = json.loads(block) except json.JSONDecodeError: data = json.loads(repair_json(block)) validate(instance=data, schema=schema) return data这个函数的关键在于分层处理:能直接解析就不修复,解析不了才走修复路径,修复后还要过 Schema 校验。这样既保证了正常情况下的性能,又覆盖了异常情况的容错。
4.3 校验失败后的重试策略
解析和校验都过了,皆大欢喜。校验没过怎么办?直接报错让上游处理,还是自动重试?我的经验是分级重试:
- 第一次失败:把错误信息(具体哪个字段、什么错误)拼回 prompt,让模型重新生成。这一步能解决大部分问题,因为模型看到具体错误后往往能自我纠正。
- 第二次失败:降低温度参数重试,或者换一个更明确的 prompt 模板。
- 第三次失败:放弃自动重试,走降级逻辑(返回默认值、转人工、抛异常)。
重试次数不要太多,三次封顶。我见过有人设十次重试,结果一个请求卡几十秒,用户体验极差,而且模型在同一个坑里反复摔,重试再多也没用。重试的关键是每次带上具体的错误反馈,而不是原样重发。
这里有个细节:重试时把上一次的错误输出也带上,让模型知道“你上次错在哪”。比如:
你上次的输出是:{"intent": "查询", "confidence": "高"} 错误:intent 的值 "查询" 不在枚举 ["query", "create", ...] 中;confidence 应为数字,实际为字符串。 请修正后重新输出。这种带反馈的重试,成功率比盲目重试高得多。
5. 完整实操:从零搭一个抗造的 Agent 输出管道
5.1 整体架构和模块划分
讲了这么多原理,下面把整套东西串起来,给你一个可以直接落地的架构。整个管道分五个模块:
- Schema 定义模块:集中管理所有 Agent 的 JSON Schema,作为唯一事实来源。
- Prompt 构建模块:从 Schema 自动生成 prompt 里的格式说明和示例。
- 模型调用模块:封装模型调用,支持温度、重试等参数。
- 解析校验模块:上面讲的那套清洗、修复、解析、校验逻辑。
- 重试与降级模块:校验失败后的反馈重试和最终降级。
这五个模块解耦,每个都能单独测试和替换。Schema 是核心,其他模块都围绕它转。这样做的好处是,新增一个 Agent 只需要定义一份 Schema,其余全部复用。
5.2 关键代码实现
先看 Schema 管理,用一个字典集中存放:
SCHEMAS = { "intent_extraction": { "type": "object", "properties": { "intent": {"type": "string", "enum": ["query", "create", "update", "delete", "unknown"]}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}, "entities": { "type": "array", "items": { "type": "object", "properties": {"key": {"type": "string"}, "value": {"type": "string"}}, "required": ["key", "value"], "additionalProperties": False } } }, "required": ["intent", "confidence"], "additionalProperties": False } }Prompt 构建从 Schema 派生,避免手写不一致:
def build_prompt(schema: dict, user_input: str, example: dict = None) -> str: schema_str = json.dumps(schema, ensure_ascii=False, indent=2) example_str = json.dumps(example, ensure_ascii=False) if example else "" return f"""你需要从用户输入中抽取结构化信息,严格按以下 JSON Schema 输出,不要输出任何额外文字。 Schema: {schema_str} 示例输出: {example_str} 用户输入:{user_input} """带反馈的重试逻辑:
def call_with_retry(user_input: str, schema: dict, max_retry: int = 3) -> dict: last_error = None last_output = None for attempt in range(max_retry): prompt = build_prompt(schema, user_input) if last_error: prompt += f"\n\n你上次的输出是:{last_output}\n错误:{last_error}\n请修正后重新输出。" raw = call_model(prompt, temperature=0.1 if attempt == 0 else 0.0) try: return parse_agent_output(raw, schema) except (ValueError, json.JSONDecodeError, ValidationError) as e: last_error = str(e) last_output = raw raise RuntimeError(f"重试 {max_retry} 次仍失败,最后错误:{last_error}")这段代码的核心是错误信息回灌。每次失败都把具体错误和上次输出带进下一轮 prompt,模型有了明确的修正目标,成功率明显提升。温度参数在重试时降到 0,减少随机性。
5.3 实测数据和调优记录
我在一个意图识别 + 实体抽取的场景里跑过对比测试,样本 500 条,结果如下:
| 方案 | 首次解析成功率 | 三次内成功率 | 平均耗时 |
|---|---|---|---|
| 纯 prompt 约束 | 82.4% | 91.2% | 1.2s |
| prompt + Schema 校验 | 82.4% | 96.8% | 1.5s |
| prompt + 校验 + 反馈重试 | 82.4% | 99.4% | 1.8s |
| 上述 + 枚举约束 | 94.6% | 99.8% | 1.8s |
几个结论很清晰。第一,枚举约束是性价比最高的优化,光这一项就把首次成功率从 82% 拉到 94%。第二,反馈重试能把最终成功率推到 99% 以上,代价是平均耗时增加 0.6 秒,这个代价完全值得。第三,Schema 校验本身不提升首次成功率,但它是重试的前提,没有校验就不知道错在哪,也就没法反馈重试。
调优过程中还发现一个细节:示例的质量比数量重要。给一个精心构造的、覆盖边界情况的示例,比给三个普通示例效果好。我一般会准备一个“标准示例”和一个“边界示例”(比如字段缺失、枚举取 unknown 的情况),两个就够。
6. 常见问题排查与避坑清单
6.1 高频问题速查表
下面这张表是我和团队在实际项目里积累的问题清单,按出现频率排序:
| 问题现象 | 根本原因 | 解决方向 |
|---|---|---|
| 解析报“Expecting value” | 输出为空或全是说明文字 | 检查 prompt 是否明确要求只输出 JSON;加重试 |
| 解析报“Extra data” | JSON 后面还有内容 | 用 extract_json_block 抠主体 |
| 校验报“is not of type” | 类型错位,数字给了字符串 | 在 prompt 里强调类型;加类型转换兜底 |
| 校验报“is not one of” | 枚举值不在范围内 | 检查枚举是否覆盖全;加 unknown 兜底 |
| 字段莫名缺失 | 模型漏输出必填字段 | 减少必填字段;prompt 里重点强调 |
| 偶发失败,无法复现 | 温度过高导致随机性 | 结构化输出场景温度设 0 到 0.2 |
| 长输入下格式漂移 | 上下文过长,模型注意力分散 | 把格式要求放在 prompt 末尾;缩短输入 |
6.2 几个反直觉的坑
有几个坑是我踩过之后才明白的,写出来给你省点时间。
坑一:以为温度设 0 就完全确定。实际上即使温度设 0,由于浮点运算和并行计算的差异,输出仍可能有微小波动。所以不能假设“温度 0 = 每次一样”,该做的校验一个都不能少。
坑二:以为 Schema 越严格越好。我一开始把所有字段都设成必填,结果失败率飙升。后来发现,模型在字段多的时候容易漏,必填字段越多,漏一个就整体失败。把非核心字段改成可选,失败率立刻下降。严格和可用之间要平衡。
坑三:忽略additionalProperties。不设这个,模型可能输出 Schema 外的字段,下游如果用了**kwargs之类的写法,可能把意外字段传进业务逻辑,埋下隐患。设成false能挡掉这类问题。
坑四:重试时不带错误信息。原样重发基本等于碰运气,模型大概率还是错。带上具体错误,模型才知道往哪改。
6.3 可观测性:别等线上崩了才查
结构化输出的问题,最怕的是“偶发”。今天好好的,明天突然挂一批,没有日志根本没法查。我的做法是每次解析都记录关键信息:原始输出、解析结果、校验错误、重试次数、最终状态。这些数据落到日志系统里,出问题能快速定位是哪个环节、哪类输入触发的。
更进一步,我会统计解析失败率的趋势。如果某个 Agent 的失败率突然从 1% 涨到 5%,说明要么模型更新了,要么输入分布变了,要么 prompt 被谁改了。这种趋势监控比单次报错更有价值,能在问题扩大前发现苗头。
还有个小技巧:把失败样本单独存一份,定期人工 review。你会发现很多失败是有规律的,比如某类输入特别容易触发某种错误,针对性地补进 prompt 示例里,失败率就下来了。这比盲目调参有效得多。
7. 一些个人经验和后续可扩展的方向
做 Agent 结构化输出这块,我最大的体会是:别把模型当数据库,要把它当一个需要引导和兜底的合作者。你越是想让它“一次到位”,越容易失望;你越是把工程约束做扎实,它反而越稳定。Schema 是契约,校验是底线,重试是保险,三者缺一不可。
还有个经验是,结构化输出的质量,很大程度上取决于 Schema 设计得好不好。我见过太多人把精力花在调 prompt 上,却忽略了 Schema 本身的问题。字段命名模糊、枚举不全、嵌套过深,这些 Schema 层面的问题,靠 prompt 是补不回来的。先把 Schema 设计对,再谈 prompt 优化。
后续如果要把这套东西做得更完善,我会往两个方向走。一是把 Schema 和业务代码的模型类打通,用 Pydantic 之类的工具从 Schema 自动生成数据类,解析出来直接是强类型对象,省掉手动转换。二是引入更细粒度的可观测性,把每次解析的耗时、失败类型、重试路径都打点,做成看板,让结构化输出的健康度一目了然。
最后分享一个我一直在用的小技巧:给每个 Agent 准备一个“金标准测试集”,几十条覆盖各种边界情况的输入,每次改 prompt 或 Schema 之后跑一遍,看成功率有没有退化。这个习惯帮我挡掉了好几次“改了一处、崩了一片”的事故。结构化输出这东西,改动的副作用往往不是立竿见影的,有个回归测试集兜着,心里踏实很多。