news 2026/10/3 5:30:32

Agent结构化输出工程化:从JSON解析到数据契约的实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent结构化输出工程化:从JSON解析到数据契约的实战指南

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 整体架构和模块划分

讲了这么多原理,下面把整套东西串起来,给你一个可以直接落地的架构。整个管道分五个模块:

  1. Schema 定义模块:集中管理所有 Agent 的 JSON Schema,作为唯一事实来源。
  2. Prompt 构建模块:从 Schema 自动生成 prompt 里的格式说明和示例。
  3. 模型调用模块:封装模型调用,支持温度、重试等参数。
  4. 解析校验模块:上面讲的那套清洗、修复、解析、校验逻辑。
  5. 重试与降级模块:校验失败后的反馈重试和最终降级。

这五个模块解耦,每个都能单独测试和替换。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 之后跑一遍,看成功率有没有退化。这个习惯帮我挡掉了好几次“改了一处、崩了一片”的事故。结构化输出这东西,改动的副作用往往不是立竿见影的,有个回归测试集兜着,心里踏实很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 5:30:11

15届蓝桥杯知识点大纲拆解:算法数据结构复习路径与避坑指南

简介&#xff1a;聚焦第十五届蓝桥杯软件赛知识点大纲&#xff0c;面向准备参赛的大学生与研究生&#xff0c;按大学C组、大学B组、研究生及大学A组三个级别系统梳理考点。内容覆盖枚举、排序、搜索、模拟、二分、高精度、DP、数学等基础模块&#xff0c;也包含背包DP、树形DP、…

作者头像 李华
网站建设 2026/10/3 5:29:54

GESP C++八级备考核心:算法思维、语言细节与实战路径全解析

带学生考了这么多年GESP&#xff0c;我越来越觉得&#xff0c;C八级是整个认证体系里最值得认真对待的一场考试。它不像一级到四级那样&#xff0c;把语法点挨个过一遍就能过&#xff0c;也不像六级、七级那样靠刷题量能堆上去&#xff0c;八级真正考的是算法设计能力和系统化的…

作者头像 李华
网站建设 2026/10/3 5:29:13

Kettle循环结果集实践:从结果集传递到Execute Row参数映射详解

简介&#xff1a;这是一份关于Kettle&#xff08;Pentaho Data Integration&#xff09;实现结果集循环获取并传递至下一转换的技术文档&#xff0c;面向有ETL开发需求的工程师&#xff0c;重点解决在Job中通过JavaScript循环处理结果集变量、再交由下一转换继续加工的问题。文…

作者头像 李华
网站建设 2026/10/3 5:29:02

Browser-Use实战:用AI语义化操控浏览器,告别脆弱选择器

浏览器自动化这个方向&#xff0c;过去两年我一直在跟。从最早的Selenium脚本&#xff0c;到后来的Playwright&#xff0c;再到各种RPA工具&#xff0c;说实话大多数方案对普通用户都不够友好——要么得写代码&#xff0c;要么得装一堆依赖&#xff0c;要么跑起来就卡死。直到我…

作者头像 李华
网站建设 2026/10/3 5:28:45

从文件描述符到线上排查:Socket网络通信实践指南

从文件描述符到线上排查&#xff1a;Socket网络通信的完整实践笔记不管你是刚跨过进程、线程这道坎&#xff0c;还是已经在Linux下写过不少IO程序&#xff0c;只要第一次认真去写Socket通信&#xff0c;基本都会卡在某一个瞬间&#xff1a;accept()卡住不动&#xff0c;客户端连…

作者头像 李华
网站建设 2026/10/3 5:28:33

GD32低功耗模式详解:睡眠、深度睡眠与待机的原理、配置与实测

一、项目概述与需求解析先说背景。去年接了个电池供电的采集终端项目&#xff0c;主控用的GD32F303&#xff0c;整机靠一颗CR2032纽扣电池供电&#xff0c;客户要求续航不少于一年。拿到需求的时候我大概估算了一下&#xff1a;如果让MCU满负荷跑&#xff0c;CR2032大概撑不过一…

作者头像 李华