我见过太多Agent项目,最后不是挂在模型效果上,而是挂在“解析失败”上。明明prompt里写了“只输出JSON”,下游拿到的却是裹在代码块里的自由文本。更麻烦的是,很多数据“看起来像JSON”,实际上每一条都踩在不同版本的雷上。这篇文章想聊的,是Agent结构化输出工程里最容易被低估的一环:如何让模型输出真正的JSON,而不是让下游解析器去猜“这大概像是JSON”。
1. 为什么Agent输出总变成“看起来像JSON”的自由文本
1.1 问题的真正源头:Token预测不是序列化
要先承认一个扎心的事实:语言模型的输出机制,本质上是逐token的概率采样,不是JSON序列化器。模型只是在海量语料里见过太多次JSON,知道JSON长什么样,知道“```json”后面通常跟着一段JSON,但它并不知道自己在被下游解析。所以当你在prompt里写“请以JSON输出”时,模型得到的指令是“用JSON这种风格组织文本”,而不是“调用一次json.dumps”。这两者之间的差别,就是自由文本混进来的入口。
我用一个生活化的类比说明:你让一个实习生“按照表格格式整理数据”,他大概率会写出一份很像表格但列对不齐、表头写错的文档。你需要的不是一个“看起来像表格”的文档,而是一份能被Excel直接读入的严格CSV。模型的处境和这个实习生类似——它见过无数种“JSON风格”的文本,其中有严格的、宽松的、甚至错误百出的。如果我们不把输出约束到“只能产生合法JSON”的路径上,它完全可能随机复现一种它见过的不规范写法。
还有一个很容易被忽视的点:指令遵循能力是随模型、随prompt、随temperature变化的。哪怕同一个模型,今天这条prompt能稳定输出合法JSON,明天加了一句“请仔细思考后再回答”,输出内容里就可能多出“好的,让我分析一下”这样的前缀。这类前缀单独看没什么,但对下游的json.loads来说,就是整段解析直接失败。
1.2 “看起来像JSON”的常见形态
我在实际项目里见过太多形形色色的伪JSON,几乎每种都能让下游解析器当场破防。下面列一份我自己的“踩坑样本库”:
| 形态 | 典型示例 | 直接后果 |
|---|---|---|
| 带Markdown围栏 | json\n{"city": "杭州"}\n | json.loads处理整段文本直接报错 |
| 中文全角标点 | {"city": "杭州":null} | 冒号、逗号不符合JSON规范 |
| 尾逗号 | ["杭州", "上海",] | JSON规范不允许数组末尾逗号 |
| 单引号包裹字符串 | {'city': '杭州'} | 合法JSON要求双引号 |
| 字段名没加引号 | {city: "杭州"} | 解析时“expecting property name” |
| 夹杂注释 | {"city": "杭州" // 用户城市} | JSON不支持注释语法 |
| 前后夹带解释 | 好的,结果如下:{"city":"杭州"} | 整段文本不再是合法JSON |
| 一次输出多个对象 | {"a":1}{"b":2} | 单次解析只能拿到第一个或全部失败 |
每一条我都不是从文档里抄的,是在真实链路里被这些格式坑过后记下来的。最典型的是“前后夹带解释”:很多模型在对话场景里被训练成“先回答,再给结果”,哪怕你反复强调“只输出JSON”,它仍然可能输出一段寒暄。如果解析端不处理这类情况,一次看起来很简单的需求,就会变成一行永远无法通过的异常日志。
1.3 解析脆弱的两条链条
这类问题的影响不是“偶尔重试一次”,而是沿着链路传导。典型链路是:Agent拿到用户需求,调用工具或生成结构化结果,下游模块读取关键字段,最后写入数据库或触发后续流程。解析环节一旦脆弱,问题就会在链路末端变成更难排查的脏数据。
第一条链条是语法层失败。json.loads抛异常,代码直接中断,或者走了某个含糊的except分支。这种情况至少报错明显,能让人知道解析挂了。真正危险的是第二条链条:语义层污染。有些解析器为了“兼容”各种伪JSON,用了正则硬匹配,比如直接搜“city”字段后面的值。一旦模型把字段命名改成“cityName”或“所在城市”,正则直接失效;更隐蔽的是,某些解析逻辑在找不到字段时会默认返回空值,业务侧拿到的不是“解析失败”,而是一个看起来合法的空对象。等到数据落地、报表跑完,才发现某列全是空的,这时候定位问题的成本已经翻了好几倍。
我也见过一些团队把这类问题简单归因于“模型不聪明”,然后反复改prompt。改prompt当然有一定作用,但如果不从输出约束和解析防御两个方向同时下手,问题永远会换个形态回来。
2. 结构化输出工程方案选型:从提示词到约束解码
2.1 方案一:提示词约束(便宜但有上限)
最朴素的做法,就是在system prompt或user message里写清楚:“不要用Markdown围栏,不要输出任何解释,只输出严格JSON。”这个方法零成本,也确实能解决一部分问题。对于指令遵循能力强的模型、低风险场景、内部demo,它往往够用。
但提示词约束的上限很明显:模型并不稳定遵循。它本质上仍然是在“尽量模仿JSON”,而不是“被强制只能生成JSON”。我见过有人在prompt里写了三遍“必须输出合法JSON”,模型依然在输出前加了一句“好的,以下是你要的JSON:”。也见过把约束写进few-shot示例后效果好了一阵,换个业务场景又打回原形。
所以我把这个方案定位成“地基”:prompt该写还是要写,但不能只依赖它。一旦链路要进生产、要对接数据库,就必须往更有强制力的方案上走。
2.2 方案二:Schema约束与约束解码(生产的正确姿势)
真正把“看起来像JSON”变成“就是JSON”,靠的是约束解码。它不是在输出后再校验,而是在生成阶段就让采样过程只能从符合JSON语法的token集合里选。技术上有几种实现:一种是基于正则或grammar的token级过滤,每次选token时把不符合语法的token概率设为负无穷;另一种是在结构化输出API里直接传JSON Schema,服务端在解码时按schema强制约束。
用一句话概括核心区别:提示词约束是“请写出JSON”,约束解码是“物理上不让你写出非法JSON”。两者的可靠性完全不在一个量级。配合JSON Schema,还能顺手约束字段名、字段类型、是否必填,甚至通过description约束具体的取值风格。这其实就是“结构化输出工程”的关键——把“描述输出格式”升级为“强制执行输出格式”。
这种方案在Agent生产环境里尤其重要。Agent通常要循环调用工具、把结果拼进下一轮上下文,如果其中任何一次的JSON结构有问题,后续所有环节都会被污染。与其在下游做一堆补救,不如在上游把合法性焊死。
2.3 方案三:函数调用/工具调用
在Agent框架里更常见的是函数调用路径。模型选择调用某个工具时,API返回的arguments字段是一个JSON字符串,而且这个字段天然受工具参数的schema约束。因为工具调用路径是经过专门强化训练的,模型输出合法参数的倾向远高于普通自由文本,所以这部分的结构化可靠性通常比“让模型直接回复JSON”高很多。
好处很明显:不需要在prompt里塞一长串JSON格式说明,也不容易夹带解释性文字。局限也很明显:不是所有场景都适合伪装成“调用函数”。如果你的业务其实就是让模型输出一段最终结果,而不是真的调用某个工具,强行套函数调用会增加理解成本;如果一次任务需要输出多个不同类型的对象,函数调用路径处理起来也不够灵活。
2.4 方案四:修复与重试兜底
无论上游约束做得多强,我都建议留一层兜底。因为还会有日志污染、模型安全策略截断、代理层篡改、网络半包等外部因素,让合法的结构化输出在传递途中变成残废文本。
兜底套路分成几步:先从整段文本里抽取JSON片段,再做常见格式修复,然后重新解析;解析失败时,把错误信息回传给模型,“你刚才的输出无法解析,错误是xxx,请重新输出严格JSON”。很多问题一次重试就能恢复,因为模型看到具体报错后能立刻调整。但要注意设置重试上限,比如最多2次,并且把每次失败样例记录下来,否则成本会被无限放大。
2.5 四类方案横向对比与选型建议
| 方案 | 可靠性 | 额外成本 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 提示词约束 | 低 | 无 | 低 | 演示、非关键链路 |
| Schema约束/约束解码 | 高 | 中 | 中 | 生产链路、数据入库 |
| 函数调用 | 较高 | 中 | 中 | Agent工具场景 |
| 修复+重试兜底 | 中 | 中 | 低 | 所有场景的底线 |
我的选型建议很简单:如果只是内部demo,用提示词约束就够了,但要清楚它有上限;如果进生产、进数据库,优先上Schema约束或函数调用;无论选哪种,修复+重试这层兜底都要留着,因为它防的不是模型,而是链路上所有可能出问题的中间环节。
3. 核心实操:让输出从一开始就是合法JSON
3.1 实操前准备:把Schema当作接口契约
做结构化输出和前端的接口对接很像,第一步不是写代码,而是定契约。先明确这次输出需要哪些字段、字段类型是什么、哪些必填,把这些写成一个JSON Schema。这里有几个经验:
- 字段命名要简单、稳定,一旦上线不要随意改。模型对字段名的遵循程度和命名清晰度直接相关,像“weather_desc”就比“desc”好得多。
- 必填字段要写全。很多模型在输出时会有“能省则省”的倾向,如果required里没写,它可能觉得某个非关键字段无所谓,干脆不输出。
- 加上description。同一字段有不同理解时,description能显著降低歧义。比如“temperature”到底要摄氏温度还是要华氏温度,必须写清楚。
- additionalProperties建议设为false。否则模型有可能输出一堆schema中没有定义的自造字段,下游解析时反而被干扰。
举个例子,定义一个天气报告的输出契约:
{ "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如杭州" }, "date": { "type": "string", "description": "日期,格式YYYY-MM-DD" }, "weather": { "type": "string", "description": "天气描述" }, "temperature": { "type": "number", "description": "摄氏温度" } }, "required": ["city", "date", "weather", "temperature"], "additionalProperties": false }别小看这个准备动作。很多项目翻车就是因为“先让模型输出看看,再决定字段”,结果模型每一次的字段风格都不一样,下游解析代码改了三版,还没上线就把时间耗光了。
3.2 结构化输出请求示例
以兼容OpenAI格式的API为例,一个典型的结构化输出请求长这样:
import json from openai import OpenAI client = OpenAI(api_key="your-api-key", base_url="your-endpoint") weather_schema = { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如杭州"}, "date": {"type": "string", "description": "日期,格式YYYY-MM-DD"}, "weather": {"type": "string", "description": "天气描述"}, "temperature": {"type": "number", "description": "摄氏温度"} }, "required": ["city", "date", "weather", "temperature"], "additionalProperties": False } response = client.chat.completions.create( model="your-model-name", messages=[ {"role": "user", "content": "杭州今天天气怎么样?"} ], temperature=0, seed=42, response_format={ "type": "json_schema", "json_schema": { "name": "weather_report", "strict": True, "schema": weather_schema } }, ) content = response.choices[0].message.content data = json.loads(content) print(data)需要说明的是,不同模型提供商的API在response_format的具体参数上有差异,比如有的支持strict、有的支持seed,有的甚至没有json_schema模式。但思路是一致的:要把schema放在API原生的结构化输出参数里,而不是只写进prompt。让服务端在做解码时就知道“只能输出符合这个schema的JSON”,比任何提示词都管用。
3.3 本地模型与约束解码的落地方式
如果你用的是本地模型,没有托管API的结构化输出能力,也不是完全没办法。一类做法是修改生成器,在采样循环里做token级约束:每生成一个token之前,基于当前已生成的内容计算合法token集合,然后把非法token的概率全部设为负无穷。这个思路可以借用llama.cpp的grammar参数,或者一些支持结构化解码的推理框架来实现。
概念上大概是这样的伪代码:
def constrained_logits(logits, grammar, partial_output): allowed_tokens = grammar.allowed_tokens(partial_output) masked = torch.full_like(logits, float("-inf")) masked[list(allowed_tokens)] = logits[list(allowed_tokens)] return masked但我的建议是:除非你有很强的推理框架维护能力,否则不要轻易自己造。约束解码的工程细节很多,包括动态grammar、批处理效率、与beam search的兼容性等,投入产出比并不高。生产环境里优先用支持结构化输出的API或成熟框架,把精力放到业务逻辑上。
3.4 实操要点:temperature、seed与max_tokens
即使用了结构化输出,生成参数依然会影响稳定性。以下是我每次必调的几项:
- temperature设为0或接近0。结构化输出不是创作任务,降低随机性永远优先。
- seed固定。凡是API支持,就固定一个seed值,至少能在回归测试时复现同样的问题。
- max_tokens要给够。JSON输出除了业务数据本身,还有大量的样板字符:花括号、引号、逗号、字段名。如果max_tokens给得太少,输出会在中途被截断,结构化API也救不了你。一个粗略的经验是:在预估业务内容长度的基础上,额外留出至少2倍的样板空间。
- 坚持“一次任务只输出一个JSON对象”。如果让模型在一个response里输出多个对象,哪怕中间用特殊分隔符隔开,也会大幅提高下游解析的复杂度。需要多个对象时,要么拆任务,要么设计成一个JSON对象数组。
4. 下游解析的防御性编程:先清洗,再解析,后校验
4.1 第一步:从自由文本里捞JSON
就算上游约束做得再好,也要防一手。因为日志、缓存、模型打印都可能混入围栏或解释性文字。我先从整段文本里把真正的JSON片段捞出来。
import re def extract_json_text(text: str) -> str: if not isinstance(text, str) or not text.strip(): raise ValueError("模型输出为空或类型异常") cleaned = text.strip() # 情况1:输出被包在Markdown围栏里 fence_pattern = re.compile(r"```(?:json)?\s*(.*?)```", re.S) match = fence_pattern.search(cleaned) if match: cleaned = match.group(1).strip() # 情况2:前后有解释性文字,定位第一个 { 或 [ start_positions = [p for p in (cleaned.find("{"), cleaned.find("[")) if p != -1] if not start_positions: raise ValueError("未找到JSON起始字符") start = min(start_positions) end_positions = [p for p in (cleaned.rfind("}"), cleaned.rfind("]")) if p != -1] if not end_positions: raise ValueError("未找到JSON结束字符") end = max(end_positions) + 1 return cleaned[start:end]这段函数解决的是“数据在哪里”的问题。先剥围栏,再取第一个左括号到最后一个右括号之间的内容,基本上能把“好的,结果是:{...}”这类噪声去掉。
4.2 第二步:多级容错解析
拿到候选JSON文本后,先用标准json.loads解析。大部分情况下这一步就够了。如果失败,再做常见格式修复:
import json def parse_json_with_fallback(text: str): candidate = extract_json_text(text) try: return json.loads(candidate), True except json.JSONDecodeError: pass # 处理尾逗号:",]" 或 ",}" repaired = re.sub(r",\s*([}\]])", r"\1", candidate) # 处理中文逗号、中文冒号 repaired = repaired.replace(",", ",").replace(":", ":") # 粗略处理单引号字段名和字符串 repaired = re.sub(r"'([^']*)'", r'"\1"', repaired) try: return json.loads(repaired), True except json.JSONDecodeError: return None, False注意,单引号替换是“粗略处理”,如果字符串内部本来就包含撇号(比如英文内容),这一步会破坏字符串内容。因此我通常只在兜底链路里用,并且会先记录一条warning日志。更安全的做法是引入JSON5之类的宽松格式解析库,或者用ast.literal_eval处理某些Python风格输出,但要先确认依赖的安全性。解析兜底的目标是“能救的尽量救”,不是“把错误文本强行变成数据”。
4.3 第三步:字段级校验与业务断言
解析成功不等于数据可用。JSON语法合法,不代表字段一定齐全、类型一定正确、取值一定在合理范围内。这一步是很多人容易略过的。
def validate_weather_report(data): required_fields = ["city", "date", "weather", "temperature"] for field in required_fields: if field not in data: raise ValueError(f"缺少字段: {field}") if not isinstance(data["city"], str) or len(data["city"]) == 0: raise ValueError("city必须是非空字符串") if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", data["date"]): raise ValueError("date必须是YYYY-MM-DD格式") if not isinstance(data["temperature"], (int, float)): raise ValueError("temperature必须是数字") if not -100 < data["temperature"] < 100: raise ValueError("temperature超出合理范围") return True这就是把schema从“请求就结束”变成“落地前还要核验一遍”。如果校验失败,不要默默吞掉,要带着错误原因回传给模型重新生成,或者至少把原始输出原样落日志,供后续排查。
4.4 一个完整示例:从模型输出到DataFrame
把上面的步骤串起来,就是一套可以复用的下游解析管线:
import pandas as pd def process_outputs(raw_outputs): records = [] errors = [] for raw in raw_outputs: try: data = parse_json_with_fallback(raw)[0] if data is None: raise ValueError("解析失败") validate_weather_report(data) records.append(data) except Exception as exc: errors.append({"raw": raw, "error": str(exc)}) df = pd.DataFrame(records) return df, errors我给这段代码加了两个返回值:一个是干净的DataFrame,一个是失败明细。看代码的人可能觉得多此一举,但在生产环境里,“失败明细”就是定位问题的眼睛。每次跑完批量任务,先看errors列表,再决定要不要重试、要不要调整prompt或schema。
5. 常见问题与排查实录
5.1 高频问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| json.loads报expecting property name | 字段名没加引号、中文冒号 | 上游启用Schema约束;下游先清洗 |
| 反复输出结构不一致 | temperature过高、未固定seed | 降低temperature、固定seed |
| 输出被截断导致JSON不完整 | max_tokens不足 | 调大max_tokens,预留样板字符空间 |
| response_format下字段仍缺失 | required没写全或字段描述歧义 | 把必填字段写全,补description |
| 一次回复里出现多个JSON对象 | 任务拆解不清晰 | 一次任务只输出一个对象/数组 |
| Markdown围栏导致解析失败 | 模型养成了围栏习惯 | 解析端先剥围栏;上游在prompt里禁用 |
| 数据落地后才发现非法值 | 只做了语法解析没做语义校验 | 增加字段级校验与业务断言 |
这张表是我每次跟团队成员交接结构化输出模块时必发的checklist。大多数问题都不是模型“坏了”,而是工程约束没做满。
5.2 三个值得细讲的现场
现场一:重试成本飙升。某个Agent批处理任务,每解析失败一次就重新调用模型,结果调用量和延迟都翻了一倍。排查后发现,失败后的重试请求没有把错误原因回传,模型第二次输出的内容和第一次几乎一模一样,等于用同样的错误结果重复付钱。改进方式很简单:重试时把上一轮的解析错误拼接进新的user message,让模型看到“city字段缺失”这类具体原因;同时设置每条目最多重试2次,超过就进入人工审核队列。
现场二:安全策略截断导致半截JSON。某次模型输出被内容安全策略在中间截断,下游拿到一个只有一半的JSON对象。这个问题在语法层很难提前预判,因为截断后的文本可能依然以合法的“{”开头。我们的处理是:解析前先做完整体校验,比如检查花括号是否闭合、字符串是否闭合;一旦发现不完整,直接放弃本次解析,不进入字段校验,也不给默认空值。宁可让任务失败一次,也不要让半截JSON流入数据库。
现场三:字段类型漂移。天气描述的字段有时候是“多云”,有时候是“晴转多云”,这些都还好。最坑的是temperature字段,模型偶尔输出“23度”而不是数字23,导致下游数据库类型约束直接报错。这个问题靠语法解析解决不了,必须靠schema里的类型约束和description约束,同时在字段级校验里做严格类型判断。后来我们在schema里给temperature加上了示例值“23”,问题出现的频率大幅下降。
5.3 避坑清单
- prompt里写再多的“你一定会严格遵守JSON格式”,都不如一次API层的结构化约束。
- 不要在下游用正则硬匹配字段值。字段名只要改一次,正则就变成定时炸弹。
- 不要把JSON里的“undefined”当null处理。合法JSON里根本没有undefined。
- 不要忽略Unicode转义。模型可能输出“\u676d\u5dde”这类转义字符串,解析后肉眼看着是乱码,但数据是对的。
- 不要忽略BOM和零宽空格。有些外部文本粘贴过来会带上不可见字符,json.loads一样会失败。
6. 一些个人体会
做Agent结构化输出工程这么久,我的体会是:它不是一个独立小需求,而是整个Agent可靠性的地基。上游能约束就不靠提示词,下游能校验就不靠肉眼。输出先按Schema约束,解析端保留一套容错清洗,再补上字段级校验,这三层下来,绝大多数“看起来像JSON”的事故都能被兜住。
最后分享一个小技巧:每次发版前跑一次回归集,把历史所有出过问题的输出样例都喂给当前模型,看解析成功率和字段校验通过率。一旦指标回退,就该检查你是否改了prompt、schema或者模型版本。这个东西平时不显眼,但在关键时刻能帮你快速定位到底是谁破坏了结构化输出。