ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent结构化输出防坑指南:如何让LLM稳定生成合法JSON

Agent结构化输出防坑指南:如何让LLM稳定生成合法JSON 上周凌晨一点我被一条告警吵醒。错误信息很简短JSONDecodeError: Expecting property name enclosed in double quotes。打开日志看到 Agent 返回的内容我愣了很久——它长得完全像一个 JSON有花括号、有键名、有冒号、有四层缩进甚至键值对顺序都很合理。但仔细一看键全部是用单引号包的最后一个对象后面还多了一行“顺便说一句这个结果仅供参考”。这不是偶发事件。做过 Agent 结构化输出工程的同行应该都有同感Agent 的输出永远在“合法 JSON”和“看起来像 JSON 的自由文本”之间反复横跳。这条路我走了大半年踩遍了各种坑从提示词硬怼到输出校验、再到解码层约束才搭出一套自己敢上线的链路。这篇文章就把这一段完整的工程化经验拆开讲讲尤其是中间那道最容易被人忽略的“输出修复层”。1. “看起来像 JSON”的灾难现场下游解析为什么这么容易崩先定义一下什么叫“看起来像 JSON 的自由文本”。这类文本不是纯 JSON但肉眼几乎分辨不出来常见形态有几种我按出现频率排个序Markdown 代码块包裹输出是json ...外层有反引号内层是合法 JSON。初学者最容易踩因为json.loads直接传进去百分百报错你必须在解析前先把代码块剥掉。键名被单引号包裹{name: 张三, age: 30}。Python 的ast.literal_eval能勉强接受但 JSON 标准里只允许双引号Java、Go、Rust 等语言的标准解析器一律拒绝。尾逗号残留数组或对象最后一个元素后面多了个逗号比如[1, 2, 3,]。这在手工编辑 JSON 时是常见习惯但对解析器来说是致命错误。值中混入注释输出里出现// 这是年龄或/* 字段说明 */之类的片段。JSON 标准没有注释。字段名和值里有非标准字符比如NaN、Infinity、undefined又或者键名带中文、带空格但没转义。废话尾巴JSON 结束后又补了一句“以上结果仅供参考”这种情况最阴间因为前半段解析没问题后面悄悄多出来的内容可能直接导致JSONDecodeError: Extra data也可能被某些宽松解析器直接忽略留下隐患。这些文本的共同特点是视觉上像 JSON语义上接近 JSON但结构上不满足 JSON 标准。下游一旦直接json.loads各种异常就来了。我整理了一张真实踩坑时最容易撞见的报错对照表输出特征解析器报错示例后果代码块包裹Expecting value: line 1 column 1 (char 0)直接失败单引号键Expecting property name enclosed in double quotes直接失败尾逗号Expecting property name enclosed in double quotes或Expecting , delimiter直接失败注释残留Expecting value或Invalid control character直接失败废话尾巴Extra data直接失败值中出现 NaN部分解析器接受数据污染后续计算异常单独看这些报错好像每条都很好解决。但在 Agent 链路里问题会被放大十倍。一个 Agent 的输出往往要传给下一个 Agent 做输入或者直接进业务系统写数据库。只要中间某一环解析挂了整条链就断了而且报错堆栈里的上下文早就被吞掉你根本不知道是哪一步产出的脏数据。更坑的是另一类“半合法”JSON。比如字段本来应该是数组Agent 没数据时直接输出null下游代码没判空就.map()直接炸一个TypeError。这类问题json.loads不会报错因为它解析成功了——只是业务语义不对。结构上合法语义上残缺这种 bug 排查成本比解析失败高一整个数量级。所以当时我就在团队里立了一个规矩所有 Agent 输出一律不允许直接json.loads裸奔。必须过一道“提取 → 修复 → 校验 → 兜底”的管线。后面几节我会把每层的设计逻辑和实际代码展开讲。2. LLM 为什么不老老实实输出 JSON格式漂移的三种根因既然要做结构化输出工程首先得理解一个问题为什么模型会产出这种不伦不类的东西大多数人第一反应是“提示词没写好”但实际原因比这个复杂。根因一LLM 没有“数据结构”实体。模型本质是在 token 概率空间里做采样它的世界里只有字符序列没有“内存中的对象”。你要求它输出 JSON它只是按照训练数据中见过的模式去模仿“JSON 的样子”。训练语料里有大量被 Markdown 包裹的 JSON、被注释污染过的 JSON、单引号写法的 JSON这些噪声模式都会被模型学到。所以在它看来单引号和双引号之间的“区别”可能只是风格差异而不是语法规则。根因二生成策略加剧了不确定性。生成时的temperature和top_p直接影响格式稳定性。温度越高token 分布越平缓模型越容易在低概率位置跳出“套路”。结构化输出场景里追求的是确定性不是创造性。我实测过同一套 prompttemperature1.0时输出格式违规率大概 8% 到 12%调到0.2以后能压到 2% 左右调到0附近基本在 1% 以内。所以如果你对输出格式有硬要求生成参数先往“最保守”调。根因三推理链污染这是最容易被忽视的。现在很多 Agent 底层用的是带推理能力的模型像 deepseek-r1、o1 这类它们会在最终答案之前先产出一大段“思考过程”。如果工程侧没有做好“只取最终答案”的处理模型偶尔会把思考链的尾巴一起吐到最终输出里比如解释到一半忽然开始给 JSON或者是 JSON 后面继续补充解释。这种输出是所有修复方案里最难的因为你很难用正则判断哪部分是垃圾哪部分是真正要的数据。针对这三种根因工程上的应对思路完全不同对“模型模仿噪声模式”靠提示词和 few-shot 引导对“采样概率漂移”靠低温度做约束对“推理链污染”靠请求参数里的 reasoning 开关加后处理兜底不能只靠一层防护。我之前犯过的错误是只在 prompt 里写“请严格按照 JSON 格式输出”然后就没有然后了。结果模型每次输出格式都变一点。后来把提示词、参数、后处理三层做成一套固定流水线才压住了这个问题。3. 第一道防线用提示词把 JSON 焊死在输出里提示词不是万能的但它是最便宜、最直接的一层。设计提示词约束 JSON 输出时有几个原则值得记下来。原则一给出结构模板不只是说“要 JSON”。“请用 JSON 格式返回结果”这句话的约束力极弱。模型只知道“返回 JSON”但不知道 JSON 里该有哪些字段、字段类型是什么、嵌套关系怎么组织。它自由发挥的空间大格式自然就飘。正确做法是把输出结构直接写进提示词像这样请严格按以下 JSON 结构返回结果不要输出任何解释、Markdown 标记或额外文字 { summary: 一段不少于 50 字的内容摘要, keywords: [关键词1, 关键词2], risk_level: low|medium|high, actions: [{name: 动作名称, params: {}}] }这样模型在生成时就有一个明确的“脚手架”字段名、字段顺序、值类型都被框定了。我自己的经验是给结构模板比给一百句“请规范输出”都管用。原则二把禁忌写具体别写万金油文案。“不要输出多余内容”这种表述模型执行得很不稳定。反过来你直接列禁忌清单效果会好很多禁止使用 Markdown 代码块包裹 JSON禁止添加注释禁止在 JSON 后添加任何说明文字键名必须使用双引号字符串值必须使用双引号禁止 NaN、undefined 等非 JSON 标准值有段时间我还在 prompt 里加了一句“不要输出感谢话和客套话”直接砍掉了大量“废话尾巴”类错误。别嫌这句多余模型是真的会跟你客气。原则三few-shot 要给“正确示例”而且示例要极端保守。给模型一个你希望的标准输出示例它能明显降低格式漂移。但要注意示例千万别给得太花哨什么缩进、注释、美化都不需要就给最朴素的纯 JSON。有些团队喜欢在示例里写// 金额之类的说明这等于亲手教模型在 JSON 里加注释后面解析等着哭。一个可以说是“作弊”的招把 JSON 嵌入 XML 标签。我自己最后采用的 prompt 模板是让模型把 JSON 放在result和/result之间。比如请将结果放在 result 标签内标签内只允许出现合法 JSON。 result {summary: ..., keywords: [...]} /result这么做有两个额外好处第一提取 JSON 时先用正则把result.../result的整体抓出来可以一次性过滤掉外层的废话第二XML 标签给了模型一个非常明确的“输出边界”它知道在这个标签内必须收敛模型执行起来比纯文本边界要稳得多。这点我在几个不同模型上都测过效果稳定优于“直接裸 JSON”的写法。参数侧的配合也不能省。我在所有 Agent 项目里结构化输出接口的温度都设在0 ~ 0.2top_p用默认或 0.9 往下收一点。个别非结构化写作任务才放宽到 0.7 以上。如果平台支持response_format或者json_schema优先开启这和后面要讲的解码层约束是两码事一个是提示词引导一个是硬限制两者叠加效果最好。不过要记住即使是开启 JSON Mode 的接口也只是“提高了合法率”不等于 100% 可靠输出层校验依然是刚需。4. 第二道防线输出校验与“伪 JSON”修复的完整打法即便提示词写得再完美也挡不住模型偶尔抽风。所以校验修复层才是这次工程的核心也是标题里那句“别让下游解析‘看起来像 JSON’的自由文本”的落点。4.1 统一提取层先回答“JSON 到底藏在哪”第一步不是解析而是“定位”。你要先从 Agent 输出文本里把候选 JSON 块揪出来。我实践下来提取规则按优先级从高到低是import re import json def extract_json_block(text: str) - str: # 1. 优先用 XML 标签提取 xml_match re.search(rresult(.*?)/result, text, re.S) if xml_match: return xml_match.group(1).strip()# 2. 再尝试提取 Markdown 代码块 fence_match re.search(r(?:json)?\s*([\s\S]*?)\s*, text) if fence_match: return fence_match.group(1).strip() # 3. 最后尝试直接找到第一个 { 和最后一个 } start text.find({) end text.rfind(}) if start ! -1 and end ! -1 and end start: return text[start:end1] return text这层放在所有解析之前能挡掉至少一半的“看起来像 JSON”问题。注意第三步的整串截取是兜底策略如果输出前后有“废话尾巴”这一步也能把尾巴除掉至少不会因为Extra data直接失败。4.2 快速修复层只做无损修复拿到候选文本后json.loads先试一遍。如果成功进校验环节如果失败就进入修复流程。我一般按以下顺序做“无损修复”每一步都是标准 JSON 允许范围内的调整去除 BOM 头和不可见控制字符把单引号键名替换成双引号用正则(?\{|,)\s*([^])\s*:处理键名部分顺便把单引号字符串值替换成双引号并转义内部双引号去除尾逗号re.sub(r,\s*([}\]]), r\1, text)去掉//和/* */注释但要小心不要把 URL 里的//误删所以这里用带行扫描的规则而不是纯正则把NaN、Infinity、None等修正为 JSON 合法值根据业务需求转成null或具体数字。我通常把这几步封装成一个repair_json_string函数逻辑不复杂但注意每一步都要在修复后再试一次json.loads一旦成功就提前返回避免过度修复引入新问题。如果你不想自己维护这一坨逻辑社区里有一些现成方案比如 Python 里的json_repair库、demjson3或者直接挂载一个解析 JSON5 的解析器。但我个人的建议是库可以辅助核心逻辑还是要自己写一遍。因为修复规则高度依赖你的业务场景比如“URL 里的 // 不能当注释删”这种判断通用库是不知道你的业务约定的。4.3 深度校验层解析成功不等于能用修复层做完json.loads能过了但这还远没到可以放行的程度。接下来必须做“语义校验”也就是拿你要求模型输出的 schema 和实际输出做对照。常见的校验点包括必填字段是否齐全字段类型是否正确比如keywords必须是数组不能是字符串枚举值是否合法比如risk_level只能取low/medium/high嵌套层级是否正确该是对象数组的地方不能直接给字符串。这一步用现成的 JSON Schema 校验库就行Python 用jsonschemaTypeScript 用zod或ajv。我偏好把 schema 定义直接复用给 prompt 生成和校验两个环节这样提示词和校验逻辑天然保持一致不会写着写着两边脱节。4.4 兜底修复层让大模型自己“擦屁股”如果上面三层全过了但文本还是修复不了说明问题已经超出规则能解决的范围。这时候别再硬啃文本了直接把解析失败的原因和原始输出一起丢回给模型让它重新生成一次。我一般是专门写一个correct_agent_output函数def correct_agent_output(raw_output: str, error_msg: str) - str: fix_prompt f 你是一个 JSON 修复器。下面是一条 Agent 输出解析时失败了。 请只返回一份合法的 JSON不要解释不要保留原始输出中的任何非 JSON 内容。 解析错误{error_msg} 原始输出 {raw_output} # 调用模型温度设为 0强制走一遍提取管线 ...这个“LLM 自修复”的兜底逻辑能把最后那 2% 的顽固问题再救回来一大部分。代价是延迟变高、token 变贵所以它只能放在规则修复全部失败之后不能一上来就调用。这层加完以后我线上 Agent 输出的最终失败率基本可以压到 0.1% 以下——剩下的可以说真就是模型抽风到再问也白问。5. 终极方案结构化生成而不是事后补救前面几节讲的是“事后补救”但从工程角度讲最高级的方案是让模型根本没机会输出非法 JSON。近年来的结构化生成技术就是在做这件事比单纯依赖提示词靠谱得多。5.1 各方案对比JSON Mode、Function Calling 与 Grammar 约束我按实际工程里的优先级把这几种方案排了一下方案原理优点缺点适用场景JSON Mode接口层做训练/解码引导提高 JSON 合法率接入简单兼容性好只是“高概率合法”并非绝对保证有的实现仍然允许空白和额外字段API 调用场景的快速增强Function Calling / Tool Calling模型输出被限制为“调用某个函数的参数”参数走专门的 schema 解析结构化程度最高字段缺失/越权情况最少需要模型能力支持函数调用和业务逻辑耦合调试稍麻烦需要多工具调用的 Agent 架构Grammar 约束解码生成 token 时用形式文法约束配合状态机只允许合法 JSON token从解码层保证合法几乎不存在格式漂移需要本地模型 特定推理框架起流式逻辑稍复杂本地部署的模型、对格式有硬要求的场景先说 JSON Mode。OpenAI 系接口里它叫response_format: {type: json_object}Gemini 也有类似能力。开启以后模型输出合法 JSON 的概率显著提升但注意它保证的是“合法 JSON”不是“符合你业务 schema 的 JSON”。你依然要过一遍字段校验。它解决的只是“看起来像 JSON”里的“看起来像”三个字。然后是 Function Calling。我认为它是目前国产 Agent 开发和 API 调用里最实用的一招。你在系统层定义好工具的parameters模型输出时会被引导成一个结构化的函数调用对象由平台负责解析参数。这个模式下模型几乎不会给你返回单引号键、尾逗号这种东西因为参数的生成路径和普通文本生成路径是隔离的。实测体验下来Function Calling 的字段完整率和类型正确率都远高于裸输出 JSON。如果你在做一个 Agent 平台需要模型稳定产出某个业务对象优先考虑封装一层“隐式工具调用”把结果从tool_calls里取出来而不是让它在正文里写 JSON。最后说 Grammar 约束。这是我这段时间最偏爱的一套方案。像llama.cpp的 GBNF、vLLM的 guided decoding、SGLang的 structured output都能在解码阶段用形式文法约束每个 token保证输出一定符合某个 JSON Schema。具体到使用就是你把想要的 JSON 结构定义成一个 schema推理框架会在采样时动态屏蔽掉那些“会导致格式错误的 token”模型根本没有办法输出单引号键或多余注释。本地跑小模型做自动化任务时这条路几乎是终极大招不然让 1.5B 的小模型背 prompt 里的格式约束真的会惨不忍睹。不过也别迷信“终极”。我见过一个案例grammar 保证了 JSON 合法性但模型在某个字段里输出了完全不合理的内容比如日期字段填了“昨天”。合法性救不了语义所以即便有了 Grammar 约束深度校验那层也不能砍。结构化输出的“完整链路”永远等于解码约束 提取修复 语义校验三件套一个都不能少。5.2 别让下游解码太宽容也别太严格做输出校验的时候有个度要拿捏好。很多同学觉得“宽容”好什么单引号、尾逗号都忍了解析成功就行。但这会掩盖真正的问题模型输出越来越随意反正不会被发现最终反噬的是业务数据的质量。反过来说如果一上来就引入 JSON Schema 严格校验字段顺序、空格、缩进这种无关紧要的东西又会造成大量不必要的失败和重试。我的建议是分两层语法校验宽松一点只要合法 JSON 就放行语义校验严格一点字段缺失、类型不对、枚举越界全部拦下来。这两层分开线上的误杀率会低很多。6. 我最终在项目里落地的一套完整链路从发现问题到把方案稳定上线我最后沉淀出一条很清晰的流水线。这里直接贴出来你可以照着搭。6.1 整体链路图用文字描述就是生成低温度 JSON Mode / Grammar → 提取XML 标签 / 代码块 / 裁剪 → 快速解析json.loads → 规则修复单引号 / 尾逗号 / 注释 / 非标值 → 深度校验json schema → 兜底重生成LLM 自修复 → 最终熔断日志告警 人工兜底翻译成伪代码大概是def parse_agent_output(text: str, schema: dict) - dict: # 1. 提取 candidate extract_json_block(text) # 2. 快速解析 try: data json.loads(candidate) except json.JSONDecodeError as e: # 3. 规则修复 candidate repair_json_string(candidate) try: data json.loads(candidate) except json.JSONDecodeError as e2: # 4. 兜底重生成 candidate correct_agent_output(text, str(e2)) data json.loads(candidate) # 5. 深度校验 validate_json_schema(data, schema) # 抛异常则走告警 return data6.2 可观测性不统计失败率的压测都是自我感动链路搭好后另一个关键工程是可观测性。我强烈建议把每个 Agent 输出样本的解析结果、失败原因、修复路径都记录下来。我自己的习惯是给每次解析打上标签direct_success直接解析成功、xml_extracted、fence_extracted、rule_repaired、llm_repaired、final_failure。这样你很快就能看到某某模型换了版本之后rule_repaired的比例从 3% 飙到 15%说明这个模型不听话了该调整 prompt 或恢复旧版本。还有一次我发现某个提示词模板的direct_success率掉到 60%排查半天才意识到是 few-shot 示例里不小心加了注释等于亲手给模型做了错误示范。另外每次final_failure的样本我都会收集起来丢进一个“失败样本测试集”。每次升级模型、改提示词、调 schema都拿这批测试集回归一遍看失败率有没有升高。这套做法成本不高但效果立竿见影能防止“修好一个 bug回归三个老问题”的恶性循环。6.3 几个容易栽的工程细节最后补几条看起来小但能省很多事的经验不要把 prompt 当契约要当概率。上线之前不管 prompt 写得自我感觉多好都要跑至少 100 条样本统计格式合规率不然别上生产。修复操作要可追溯。记录修复前后的文本 diff否则哪天线上出现诡异数据你根本不知道是模型生成的还是修复层改坏的。schema 版本要和代码版本绑定。字段改了旧数据、旧缓存全都要跟着迁移尤其是 Agent 任务队列里的存量请求很容易被遗漏。提取层和修复层一定要有单元测试。把那些年遇到的诡异输出全部固化成测试用例比如“带 URL 的字符串不能把 // 当注释删掉”这类回归测试能拦住后续极其多的改动风险。说真的做完这整套链路之后的感受是Agent 结构化输出的难点不在“让模型理解 JSON”而在“承认模型不是每次都能输出 JSON并在这个前提下设计一套高容错、强校验、可观测的解析管线”。不要把希望寄托在模型的“自觉”上把每一层都当成它随时会失守来设计最终的整体稳定性才真正可控。
返回列表