首页 / 资讯中心 / 文章详情

大模型结构化输出实战:Agent 下游 JSON 解析不再崩

大模型结构化输出实战:Agent 下游 JSON 解析不再崩 ★ FEATURED ARTICLE
我接过不少 Agent 项目最让我头大的从来不是模型“不聪明”而是下游解析器突然抛出一个json.decoder.JSONDecodeError。翻看日志模型明明给了一坨“看起来像 JSON”的东西有花括号、有冒号、有缩进甚至还有换行。但你只要敢把它丢给json.loads()它当场崩给你看。这种由自由文本伪装成的结构化数据足以让整个 Agent 链路从“几乎成功”变成“全线瘫痪”。这个问题的根源在于大模型输出的本质是概率采样下的自由文本而不是 SQL 那样有强约束的结构化数据。它觉得“长得像 JSON 就算完成任务”但下游解析器只认“合法 JSON”。于是做 Agent 的人必须补上这层工程能力——结构化输出工程。这篇文章我会从原理、技术路线、实操代码、排坑经验四个层面把这件事讲透。适合正在做 AI Agent、工具链、任务流水线的开发者参考尤其是被下游解析反复折磨过的人。1. 为什么“看起来像 JSON”的自由文本会毁掉整条链路1.1 大模型输出的本质概率采样产物很多人对 LLM 有个误解觉得它既然能写代码、能对 JSON Schema 点头输出理应“八九不离十”。但大模型的生成过程是在词表上按概率分布逐个 token 采样。它没有数据库表结构也没有编译器替它做语法检查。它甚至没有“合法”这个概念只有“看起来合理”这个概念。类比一下你让一个实习生手抄一份合同他大概率会把格式抄得“差不多”但某个字段漏了、某个引号变成中文全角、某段话自己加了几句这些都完全可能发生。下游如果是个严格的法务系统这份合同直接作废。LLM 输出的 JSON就是这种“手抄合同”——大多数时候能看但不能直接进系统。所以做 Agent 工程第一课就是模型输出的一切字符串默认都不可信。所谓“可信的结构化数据”必须由下游那层解析和校验逻辑来兜底而不是靠模型自觉。1.2 “看起来像 JSON”到底差在哪里六类典型污染我在生产环境里归纳了一下模型输出的“伪 JSON”大概有六类。每一类都能单独击穿解析器第一Markdown 代码块包裹。模型习惯性把 JSON 塞进json和之间直接json.loads()必然失败。第二前置废话。它会在 JSON 前面加一句“好的这是你要的结果”这行文本直接让解析器懵掉。第三非法字符。中文全角冒号、全角引号、奇怪的转义符号都会让 JSON 解析器报Expecting : delimiter。第四尾逗号。JSON 标准禁止数组或对象最后一个元素后面带逗号但模型经常不管这个。第五注释。模型在 JSON 里写// 这是表示金额严格模式下一律报错。第六键名漂移。Schema 里定义的是final_answer模型自作主张改成the_final_answer语法合法但业务字段对不上比语法错误更隐蔽。下面这个例子就是典型的混合污染你可以拿它测试一下你的解析器{ result / final_answer: the answer is 42, items: [1, 2, 3,], note: 以下是我的输出, // 其实我有点不确定 ok: maybe }这里面几乎没有一处能通过合法性校验键名带空格和斜杠、数组尾逗号、行注释、maybe裸值。每一处都是模型“自由发挥”的结果而自由恰恰是结构化输出的大敌。1.3 对 Agent 链路的连锁破坏有人觉得解析失败无非重试一次能有多大影响在真实 Agent 系统里结构化输出是任务编排、记忆存储、工具调用、分支决策之间的“接口契约”。它断一次不只是丢掉一条消息而是可能导致整条流水线错乱。举例一个 Agent 调度器根据模型的{ intent: book_flight, params: {...} }决定调用哪个工具。如果解析失败调度器可能直接走了catch分支把任务标记为“未知意图”用户被误导到错误流程。如果解析成功但字段漂移比如params里的date变成departure_date工具调用就会拿到一个缺了date的参数对象接口报 400然后又是一轮无意义的重试。更可怕的是这些错误往往只在某一种输入样本下出现测试环境跑得好好的一上生产就随机炸。所以结构化输出不是说“尽量给个好格式”而是必须从工程上让“看起来像 JSON”永远没有机会流到下游。这就引出下面这三条技术路线。2. 三种主流结构化输出技术路线选型与原理2.1 提示词约束成本最低但最不稳定最朴素的做法是在 prompt 里写“请只输出 JSON不要输出其他内容”顺便给一个 few-shot 例子。成本为零接入最快任何模型都能用。但它的可靠性完全取决于模型的“配合意愿”。模型没有语法感知能力它无法意识到“多一个前置句子会导致下游崩溃”。prompt 里的要求在它看来只是众多“建议”中的一条不是硬约束。我见过一个模型prompt 里写了三遍“strictly JSON”它依旧在开头输出Here is your JSON:。你说模型坏吗倒也不是它只是对“语气助词”没有抵抗力。提示词约束适合做什么适合给一件“即使失败也只是难看一点”的事情用比如让模型写一段对话草稿、起名字、做头脑风暴。它不适合做强依赖的接口契约。生产环境的 Agent 任务编排不能把命交给 prompt 的“自觉”。2.2 校验重试循环最接地气的保底方案第二路线是“解析 → 失败 → 把错误信息回传 → 让模型重新生成”的循环。这个思路很朴素但非常有效因为 LLM 看到具体的解析错误后确实有很大概率自行修正。核心代码结构大致是import json import jsonschema def parse_structured_output(raw_text: str, schema: dict, max_retries: int 2) - dict: for attempt in range(max_retries 1): try: obj json.loads(raw_text) jsonschema.validate(obj, schema) return obj except Exception as e: if attempt max_retries: raise # 把错误信息拼回 prompt让模型重新生成 raw_text ask_llm_with_error_feedback(raw_text, str(e)) raise ValueError(Failed to parse structured output)这个方案的优点是不依赖特定模型、不依赖特定推理框架只要模型支持文本输入输出就能跑。缺点也很明显每一次失败都要重复调用模型消耗 token、增加延迟而且碰到“键名漂移”这类语义错误时模型重试也不一定能改对。我的建议是把它当作保底方案而不是主方案。校验重试解决的是“偶尔手滑”的问题解决不了“系统性格式失控”的问题。2.3 约束解码在采样层把非 JSON token 挡在门外第三条路线也是最硬核的一条叫约束解码。它直接在模型采样阶段做干预。模型不是按概率分布自由选择下一个 token 吗那我们就根据当前正在生成的语法位置把不合法的 token 全部过滤掉只让模型在合法候选词中挑一个。这套机制怎么实现先把 JSON Schema 编译成一个有限状态机也就是“当前这个位置接下来可能是键名、冒号、字符串、数字、右括号……”然后每一步生成前根据词表里每个候选 token 是否能被状态机接受把概率重算只保留合法 token。这样生成出来的内容在语法层面几乎必然合法。现在不少开源库都支持这种方案常见的有 Outlines、Guidance、Jsonformer、LMQL它们在不同程度上支持用正则、JSON Schema 或上下文无关文法来约束采样。也有不少商业模型 API 提供了原生的response_format或 structured output 能力底层思路类似只是把约束放到了服务端。但约束解码不是银弹。它只是保证“语法合法”不保证“语义正确”。模型在合法语法里照样可以把price: -500填进去也能把city: 北京填进一个只允许北京市的枚举字段。另外约束解码通常会增加生成延迟因为每一步都要对词表做一次过滤或状态判断对并发和吞吐有一定影响。所以在需要强实时响应的场景要评估能不能接受这个额外开销。2.4 选型建议不同场景的匹配我把三种路线放在一张表里方便团队在方案评审时直接对号入座场景推荐路线理由快速原型、Demo、非关键展示提示词约束最快成本最低出错无大碍生产环境、模型 API 受限Schema 校验重试不挑模型改动小能用保底生产环境、可自定义推理栈约束解码 校验重试语法合规率高双重保险Agent 工具调用function calling优先用原生结构化输出参数厂商已封装兼容性最好高并发、低延迟场景校验重试为主慎用重约束约束解码的延迟可能成为瓶颈我实际推荐的做法是不管选哪条主路线校验层永远要存在。约束解码负责让模型“说不出话”校验层负责让错误“进不了门”。3. 实操搭建一套可靠的结构化输出层3.1 先定义 JSON Schema而不是先写提示词很多人做 Agent 的第一步是疯狂润色 prompt把“请按照以下格式输出”写在最前面但 JSON Schema 一个字节都没写。这是反的。正确顺序是先把契约写清楚再让 prompt 去伺候契约。比如一个“订单信息提取”任务我会先定义这样的 Schema{ $schema: http://json-schema.org/draft-07/schema#, type: object, additionalProperties: false, required: [order_id, items, total_amount], properties: { order_id: { type: string, pattern: ^ORD-[0-9]{6}$ }, items: { type: array, minItems: 1, items: { type: object, additionalProperties: false, required: [name, quantity, unit_price], properties: { name: { type: string }, quantity: { type: integer, minimum: 1 }, unit_price: { type: number, minimum: 0 } } } }, total_amount: { type: number, minimum: 0 } } }关键点有三个required明确必填字段additionalProperties: false让多余字段直接报错minimum和pattern把取值范围收紧。这样下游拿到对象之后不用再写一堆if obj.get(order_id)的防御代码因为不合规的在边界已经被拦截了。为什么说 Schema 要写在 prompt 前面因为模型对 JSON 的理解本质上依赖对结构的感知。你把一个完整、精确、带约束的 Schema 丢给它它才知道哪些字段不能缺、哪些值不能乱来你只给一句“按格式输出”它只能猜。3.2 双保险链路约束解码兜底 校验层把关我在生产环境里最常用的架构是一套“三明治式”的处理链路。第一层是生成端可以采用约束解码、原生 structured output或者退而求其次用普通采样第二层是解析与修复把各种脏数据尽量恢复正常第三层是 Schema 校验和业务校验任何数据过了这关下游才认。一个可落地的伪代码如下import json import jsonschema def agent_structured_call(prompt: str, schema: dict) - dict: # 1. 生成优先走约束解码否则普通生成 raw_text llm_generate( prompt, json_schemaschema, # 后端支持约束解码时传入 response_formatjson # API 支持时使用 ) # 2. 清理剥离 markdown 围栏、前置废话 cleaned extract_json_block(raw_text) # 3. 解析严格模式失败时尝试修复 try: obj json.loads(cleaned, strictTrue) except json.JSONDecodeError: obj repair_and_parse(cleaned) # 修复尾逗号、单引号等作最后急救 # 4. 校验不合规则带着错误信息重试 for retry in range(2): try: jsonschema.validate(obj, schema) return obj except jsonschema.ValidationError as e: if retry 1: raise obj ask_llm_to_fix(obj, str(e)) raise ValueError(structured output validation failed)这里每一层都有它存在的理由。生成端约束解决 80% 的“格式失控”清理层解决模型爱说废话、爱加代码块的问题解析修复层处理那些“就差一点就能救回来”的脏 JSON校验层是最终的门卫。不要只依赖其中任何一个环节尤其是不要把“解析修复”当生产依赖——它是急救包不是长期方案。3.3 让 JSON Schema“活”起来增加容错与宽容度有些时候我们明知道模型可能输出稍微偏一点的值但业务又不希望直接判死。这时候可以给 Schema 加一点“弹性”。我常用的做法有三种。第一类型宽容Schema 里允许type: [number, string]然后在校验后统一做类型转换这样模型输出12而不是12时不会直接挂掉。第二枚举兼容不要只写一个枚举而是把常见俗写也放进去或者在校验之前做一个“别名映射”比如北京自动映射成北京市。第三默认值兜底某些非必填字段缺失时用default补一个合理值避免下游因为缺一个可选项就报错。这里有个分寸问题过度宽容会让 Schema 失去约束力等于又退回了自由文本时代。我的原则是语法层面保持严格语义层面保留弹性。additionalProperties依然开false必填字段依然必须有但字段内的取值规则可以适当放宽让模型有轻微容错空间。3.4 工程细节在解析 JSON 时容易踩的坑解析环节看着简单实际上坑很多。我列四个最容易踩的第一绝对不要用eval()或ast.literal_eval()去解析模型输出。eval()能执行任意表达式模型输出只要被人为注入一段危险代码后果不可控。ast.literal_eval()虽然安全一点但它的语法规则和 JSON 也不同一样不该用来处理生产数据。第二json.loads()的strict参数默认开着遇到全角引号、默认字符集问题会直接报错。这个严格模式是对的别乱关。真正该做的是在解析前清理非法字符而不是关闭检查标准。第三处理流式输出时不要让下游一边收 chunk 一边json.loads()。你应该在缓冲区里累积等收到一个完整的 JSON 块再解析。如果确实需要边收边解析可以考虑增量 JSON 解析库但它对每一帧的合法前缀要求很高通常还需要配合约束解码一起用。第四别忘记存储时的编码问题。把解析后的 dict 写进文件或消息队列时如果用了默认的json.dumps()中文会被转成\uXXXX形式看起来不舒服不说后续排查还多一层转换。保存时记得ensure_asciiFalse。4. 常见问题与排查技巧实录4.1 类型漂移数字变成字符串这是我在线上遇到频率最高的类型问题。模型把订单金额{total: 198.00}输出成{total: 198.00元}。原因通常是模型从一段带单位、带千分位的文本里提取数值顺手把符号也带进去了。排查时你会在校验阶段看到198.00元 is not of type number之类的报错。修复分两层。第一层在 Schema 里对关键数值字段同时放宽为number或string然后在校验后做清洗把198.00元里的非数字字符去掉再转 float。第二层在 prompt 里明确说“金额字段只输出纯数字不要包含货币符号和单位”。这两层缺一不可因为 prompt 改善的是概率Schema 和清洗兜底的是确定性。4.2 键名漂移模型把字段名翻译成同义词键名漂移真的很恶心。你在 Schema 里定义了summary模型非要输出summarize。你说它有错吗从语义上没有从契约上就是违约。下游按obj[summary]取值直接KeyError。排查这类问题要先判断是全局性的还是零星性的。全局性漂移说明 prompt 对字段名的强调不够你需要把字段名原样写进 few-shot 示例并且明确说“字段名必须与 Schema 完全一致不能使用同义词”。零星漂移则建议做一个字段名归一化层把常见同义词映射到标准字段名比如summarize、summary_text都映射到summary。但注意这只是急诊治本还是得靠 prompt 和示例。4.3 输出被截断max_tokens 不够输出截断几乎每几天就会遇到一次。症状是 JSON 从某个很长的字符串中间断掉解析报Unterminated string starting at或Expecting value。常见原因有三max_tokens设得太小、别的 stop 条件提前触发、或者单字段内容过长。排查流程先看日志里finish_reason是length还是stop。如果是length基本就是截断如果是stop检查是不是你自设的停止词把 JSON 腰斩了。修复办法有三个层次短期的做法是把max_tokens提升到预估输出的 1.5 倍中期的做法是限制输出字段的字符长度比如在 Schema 里给maxLength长期的做法是把大段内容拆成多次结构化调用每次只返回一个小对象避免一次生成超长文本。4.4 约束解码与高并发的冲突约束解码虽然好用但它有性能代价。它需要每一步生成时在校验状态机上做 token 过滤这比普通采样多了一层计算。当并发请求上来时推理引擎的吞吐会下降。很多人问“AI Agent 怎么扛并发”我的回答是不要把并发压力全部丢给单次推理Agent 系统的并发瓶颈往往不在模型卡而在编排层和解析层。实际操作上建议在 harness 层做异步调度和限流比如用任务队列把高并发请求削峰填谷对每个生成请求设置超时和重试对结构化输出层单独做线程池隔离防止解析失败拖垮整个服务。再有就是针对不同的后端实测约束解码的延迟增量一般模型规模越小、词表越大这个增量越明显。用数据决定要不要给约束解码开小流量开关。4.5 流式输出场景下如何确保证结构完整流式输出本身挺好的用户体验好、首字延迟低。但它和 JSON 解析天然有点冲突你收到第一帧内容只是一半没法解析。最稳妥的策略是“流式展示完整后再解析”把缓冲区设为“流式接收 阈值截断”等收到满足完整 JSON 条件的缓冲区后再交给结构化输出层。可以在末尾检测到}且字符相等时认为可能完整然后尝试json.loads()失败继续累积。如果需要边输出边做结构化处理比如边生成边构建表格那就得用增量 JSON 解析器配合约束解码让每一帧都保持合法语法前缀。这个方案工程复杂度高一般场景不值得除非你有强实时消费需求。下面这张速查表是我贴在团队文档里的排查时照着做基本能解决 80% 的问题症状常见原因处理方向JSONDecodeError尾逗号模型自由输出清理层修复尾逗号约束解码兜底类型漂移数字变字符串提取时带单位或符号Schema 放宽类型清洗层转换KeyError键名不同模型用了同义词few-shot 强调字段名映射同义词输出中断引号未闭合max_tokens 或 stop 截断调大 max_tokens限制字段长度解析慢吞吐下降约束解码开销大异步调度限流实测延迟增量流式解析失败不完整帧缓冲完整块后再解析必要时增量解析5. 结构化之上语义校验与测试回归5.1 语法合规不等于业务正确把 JSON Schema 校验过了不代表输出就能直接用。语法合法语义可能完全不合理。模型可以在完全合法的 JSON 里给你一个负数金额、一个不在任何枚举里的城市名、一段逻辑上自相矛盾的日期区间。举一个我真实踩过的例子一个 Agent 任务做“方案可行性评估”模型输出{status: normal, confidence: 0.95}Schema 校验通过语法没问题。但下游的分流规则是confidence 0.98才走自动执行0.95 应该人工复核。当时因为没有语义校验层0.95 直接被当成“可自动执行”流程就这么悄悄跑偏了。谁都没报错但结果就是不对。所以我在 JSON Schema 之上一定还会加一层 Pydantic 模型或手写的业务校验器。Pydantic 的好处是能从类型和约束上再做一遍强校验而且我们对字段的具体取值规则、业务关系判断都能写进去比如“结束时间必须晚于开始时间”“金额必须大于 0”“枚举值必须属于允许列表”。这层校验不是锦上添花是保证“合法但不合理”的数据无法进入下游的最后一堵墙。5.2 把结构化输出写进测试回归结构化输出是强工程属性必须有测试保护。我最推荐的是“固定 Schema 固定 Prompt 集”的回归测试。具体做法是维护一批覆盖各种边界情况的测试样本每次改动 prompt 或解析逻辑后批量跑一遍看 JSON 成功率、字段正确率、失败分布。用快照把历史输出存下来方便对比回归。下面是一个极简的测试思路import pytest from agent import agent_structured_call from jsonschema import validate TEST_CASES [ (订单提取-正常, 订单号 ORD-123456金额 100.5, {total_amount: 100.5}), (订单提取-金额带单位, 订单号 ORD-654321金额 100.5元, {total_amount: 100.5}), (订单提取-无订单号, 这是一个没有订单号的文本, None), ] def test_structured_extraction(): for name, prompt, expected in TEST_CASES: result agent_structured_call(prompt, ORDER_SCHEMA) validate(result, ORDER_SCHEMA) if expected is not None: assert result[total_amount] expected[total_amount]这套测试跑起来之后你会发现 prompt 的一点点改动都可能让某个边缘用例突然变差。这就是回归测试的价值不靠感觉判断“这次改得更好”而是用成功率数字说话。5.3 监控与日志你不能盲目相信“成功解析”最后生产环境里一定要给结构化输出层加监控。不要只看“最终成功还是失败”而是要把过程指标拆开看parse_fail_count、retry_count、repair_count、schema_validate_fail_count。这几个指标能精确告诉你问题出在生成端、解析端还是语义端。我的经验是设置几个简单的告警阈值parse_fail_rate 1%时进入观察 5%时暂停模型版本灰度退回上一个稳定版本。这个数字不一定适用于所有项目但思路一定是这样。否则你可能在一个模型更新之后默默吞了两周的解析错误用户早就在骂了你还在看“成功率 99%”的假象。6. 最后几个工程经验和布道聊到这儿我想把团队在内部分享时反复强调的几条规矩再念叨一遍都是拿事故换来的。第一条凡是模型返回的字符串一律不信任。不管你的模型有多强、prompt 写得有多细只要字符串还没经过解析、校验、业务判断三层关卡它就只是自由文本不是数据。第二条JSON Schema 是接口文档必须版本化。每次改 Schema都要像改 API 参数一样走评审下游同步升级不能一个人在暗处偷偷改。我见过太多项目Schema 和实际代码早就对不上了改了一处字段下游三天后才爆出来。第三条结构化输出层一定要有双保险。能上约束解码就上约束解码没有条件就老老实实做校验重试。但无论哪条路校验与语义检查永远不要省。这是你整个 Agent 系统的最后一道安全网省掉它等于把合同审核外包给那个“手抄合同的实习生”。最后再分享一个小技巧。我在每个新 Agent 项目启动的第一天就会先把目标输出 JSON Schema 写好然后直接跑一个空的 prompt 让它输出“晴天”数据。等真正开始调整 prompt 时再往 Schema 里补充失败样本的反例约束。先写 Schema 再写 prompt让模型从第一天就开始贴着契约生成而不是等它自由发挥几天之后再回来收拾旧账。这个顺序帮我省掉了至少一半的解析事故。你可以把这套方法论直接复制到自己的项目里。先定义 Schema再加一层校验然后把监控和回归测试配上你的 Agent 下游就不会再是那个天天替你挡刀、还要背锅的json.loads了。
阅读完成 · 觉得有帮助?
咨询建站