刚接手一个Agent项目时,我遇到过一个特别典型的翻车现场:模型信誓旦旦返回了一段看起来完美无缺的JSON,我高高兴兴拿去给下游服务解析,结果json.loads直接抛异常。定睛一看,这段“JSON”外面裹着一层```json代码块围栏,前面还加了一句“好的,这是你要的JSON:”。那一刻我意识到,做Agent开发,真正要解决的不是“让模型说人话”,而是“让模型按契约交付”。这篇就聊聊结构化输出工程——怎么别让下游解析“看起来像JSON”的自由文本,而是拿到真正可靠、可校验、可重试的JSON。
这事儿适合谁?如果你在搞AI Agent开发、正在接大模型API做业务系统,或者曾经被模型输出折腾得想自己写解析器,那这篇文章应该能帮你少踩不少坑。我会从原理讲清楚模型为什么“不好好说话”,再给出三层防线和一套可以直接抄作业的修复管线,最后整理一份排查速查表。
1. 为什么模型输出总是“看起来像JSON”
1.1 模型是概率推理器,不是格式化引擎
先想明白一件事:大语言模型本质上是在做“下一个token的概率预测”。它看过海量文本,知道JSON长什么样,也看过无数人把JSON包在markdown代码块里,还知道回复别人问题时通常先说一句客套话。于是当你让它“返回JSON”时,它把这些概率叠加在一起,产出了一段“统计上最像人话的JSON”——
对它来说,输出合法JSON和输出一段解释文字之间的优先级其实是模糊的。这不怪模型,怪我们的认知偏差:我们把它当成了编译器或者序列化库,觉得格式是天然保证的,但模型从没承诺过自己是格式化引擎。它承诺的是“扮演一个懂格式的助手”,至于格式在哪个层面严格成立,取决于提示词和采样参数的综合作用。
这个认知是后面所有工程手段的基础:你不可能靠运气获得稳定JSON,必须靠机制去兜底。结构化输出(Structured Output)这个概念之所以在Agent开发里越来越重要,就是因为Agent场景下模型输出会经过多层下游,任何一个环节的自由文本都可能让整个链路崩掉。
1.2 三类最常见的“脏JSON”
我做过一次小范围统计,把几个主流模型在“请输出JSON”指令下的裸输出拉出来看,脏数据基本逃不出这三类:
第一类是包裹型,最典型的就是带markdown代码块围栏,前面还有各种说明文字。这类其实还算好的,因为至少内容完整,剥掉围栏就能用。
第二类是夹带型,模型在JSON数组中间穿插注释,或者在结尾加一句“以上数据仅供参考,使用时请注意字段含义”。这种最恶心,因为语法没坏,但语义被污染了,你解析完还要再清洗一遍。
第三类是破损型,包括但不限于:尾逗号、key没加引号、字符串里的引号没转义、单引号代替双引号、输出到一半被截断。这类是真正让json.loads当场死亡的凶手。
这三类脏输出不是模型“笨”,而是它训练的语料里本身就充斥着各种不规范JSON——GitHub上的代码、论坛里的提问、教程里的示例,全都是脏的。模型只是忠实地模仿了它见过的分布而已。
1.3 自由文本引发的连锁灾难
如果只是解析失败也就罢了,重新调用一次就行。但Agent场景下问题会像滚雪球一样放大——下游拿到的不是JSON,再往下传的时候可能把错误信息本身当成业务数据;重试如果带着上次的完整对话历史,模型反而更容易继续犯同样的错;如果上游模型在JSON里塞了一个格式合法但业务语义错位的字段,你甚至来不及发现,数据已经落库了。这种“看起来像JSON”的自由文本,比纯文本更危险,因为语法上它骗过了你的解析器,语义上却在你没注意的地方漏了气。
所以,结构化输出工程的本质,是给模型的自由意志套上一件“制度约束”,让下游永远只跟结构化数据打交道。
2. 三条主流防线:从提示词祈祷到Schema契约
2.1 提示词约束:最便宜,但别指望它兜底
第一道防线当然是在提示词里写清楚要求。我见过最考究的写法是这样:
请仅输出合法的JSON对象,不要包含markdown代码块标记,不要添加任何解释性文字。 JSON必须符合以下结构:{"name": string, "age": integer}。这套写法有用吗?有用,但作用有限。它能显著降低包裹型脏输出的概率,因为模型确实会“遵从”指示;但对于破损型脏输出,比如尾逗号、截断、特殊字符没转义,提示词几乎无能为力。原因也很简单:模型生成JSON时是按概率逐token走的,它不会在生成完整个对象后做一次语法校验——“我不会犯错”这个意识,模型并不具备。
所以我的经验是,提示词约束做的是“降低出错率”而不是“保证正确性”,它能让你从百分百翻车变成百分之二三十翻车,剩下的交给后面的机制。
2.2 结构化输出API:让平台替你约束
比提示词更硬核的,是直接用平台提供的结构化输出能力。现在主流的模型API基本都有对应方案,虽然各家叫法不一样,但思路是共通的:
一种叫JSON Mode,你告诉它“输出必须是合法JSON”,平台在采样或后处理阶段做约束,常见于OpenAI兼容接口。另一种叫Function Calling/Tool Calling,你定义好工具的参数Schema,模型只会按这个Schema输出参数对象,不给你发挥空间——这是做Agent时最推荐的一招,因为你本来就要让Agent“调用工具”,顺手把输出结构约束在工具参数里,一鱼两吃。
各家实现细节有差异,有些平台是真的在生成时做约束,有些只是“生成后校验,不合法就重试”,但对我们调用方来说,结果是一致的:结构化程度大幅提升。如果你用的模型API支持这类能力,优先用,它比你自己写提示词可靠一个量级。
注意:不同平台的“结构化输出”能力并不等价。我在实际项目里遇到过某家模型声称支持JSON Schema约束,但嵌套深了照样返回残缺JSON。所以即便用了平台能力,下游该校验还是得校验,别把单一机制当银弹。
2.3 JSON Schema校验:把格式变成契约
第三道防线,也是我认为最值得投入的,是真正把“格式要求”落成一份机器可读的契约——JSON Schema。这是我在Agent开发中养成的习惯:先定义Schema,再写提示词,再对接API,最后才是写业务逻辑。
光有Schema还不够,它只是“评判标准”,你需要把它嵌入到工程流程里:模型输出 → 解析尝试 → Schema校验 → 不合格就触发修复 → 再校验。循环直到通过或者达到重试上限。这套机制的好处是,它让“模型输出是否合格”从主观感受变成了客观判断,你可以给每次输出打出明确的“通过与不通过”,为后续的质量监控打基础。
3. 实操:搭建一套可靠的结构化输出管线
3.1 技术选型:Pydantic + 兼容层 + 修复器
下面分享一套我实际在用的方案。语言选Python,理由很俗:生态成熟,团队招人容易,写起来快。核心依赖是Pydantic——它既能定义数据模型,又能顺手做数据校验,还能直接生成JSON Schema,一举三得。模型API我习惯走OpenAI兼容格式,因为几乎所有模型平台都兼容这套协议,换模型不换代码。
除了这两个,我还要准备一个“修复器”。修复器可以是一个小型规则集,也可以让模型自己当修复器(把错误信息喂回去让它改),建议先从规则集做起,后面再上模型修复,成本更可控。
3.2 定义Schema:好的契约长什么样
先看一个反面教材。很多人定义结构时只写字段名和类型,比如:
from typing import List, Optional from pydantic import BaseModel, Field class OrderItem(BaseModel): name: str quantity: int price: float class Order(BaseModel): order_id: str items: List[OrderItem]断言一下就知道,这个模型太“空”了——模型不知道quantity的取值范围,不知道price是不是必须大于零,更不知道order_id的格式要求。模型在输出时全靠猜,校验时也无从“卡关”。
好的Schema应该像一份详细的验收单:
class OrderItem(BaseModel): name: str = Field(description="商品名称,不可为空") quantity: int = Field(ge=1, le=999, description="商品数量,至少1件") price: float = Field(gt=0, description="单价,必须大于0") class Order(BaseModel): order_id: str = Field(pattern=r"^ORD-\d{6}$", description="订单号,格式ORD-后跟6位数字") items: List[OrderItem] = Field(min_length=1, description="订单商品列表,至少一项")每个字段的description不只是注释,它会随Schema一起传给模型,等于在提示词之外又多了一层约束暗示。ge/le/pattern/min_length这些约束则是给校验器用的,模型输出一旦越界,校验立刻失败,进入修复流程。
3.3 核心Pipeline:生成 → 校验 → 修复 → 重试
现在的核心是整条管线怎么写。先说生成环节,调用API时我用的是工具调用方式,把上面定义的Order模型作为工具参数传出去,让模型“以为”它在调用一个创建订单的工具,实际我们只是要它的参数。
然后是校验环节。这里有个小技巧:拿到模型返回的参数字符串后,不要直接json.loads,而是先交给Pydantic的model_validate_json。这个方法内部会先解析JSON再校验字段,任何一个环节出错都能给出明确异常,方便我们定位“是语法坏了还是业务约束越界了”。
再往下是修复器。我最简单的一版修复逻辑是这样:
def repair_with_rules(raw: str, schema: type[BaseModel]) -> Optional[BaseModel]: # 尝试直接解析 try: return schema.model_validate_json(raw) except Exception as e: last_error = str(e) # 去掉markdown代码块围栏 cleaned = raw.strip() if cleaned.startswith("```"): cleaned = re.sub(r"^```(?:json)?", "", cleaned).strip() cleaned = re.sub(r"```$", "", cleaned).strip() try: return schema.model_validate_json(cleaned) except Exception as e: last_error = str(e) # 规则:修复尾逗号 cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned) try: return schema.model_validate_json(cleaned) except Exception as e: last_error = str(e) return None这套规则写的顺序是有讲究的:先处理最可能导致成功的修复,再做成本更高的操作。我实际还加过“提取自由度更高”的规则——比如用正则找到第一个{和最后一个},把中间部分全当JSON截取出来,专治模型开头结尾夹带说明文字的坏习惯。
如果规则修复也不成功,最后一道救兵是让模型自己修。做法是把原始输出和校验异常信息拼进一个新的提示词里,让模型“在上文基础上仅输出修正后的JSON”。注意,这一步我会刻意避免带上完整对话历史,只带当前出错的输出和错误信息,否则模型更容易在长上下文里迷失和重复犯错。
完整的管线代码大概是这个骨架:
from openai import OpenAI client = OpenAI(base_url="...", api_key="...") def generate_structured(prompt: str, schema: type[BaseModel], max_retries: int = 3): schema_json = schema.model_json_schema() for attempt in range(max_retries): resp = client.chat.completions.create( model="your-model", temperature=0.0, tools=[{ "type": "function", "function": { "name": "submit_order", "description": "提交订单数据", "parameters": schema_json } }], tool_choice={"type": "function", "function": {"name": "submit_order"}}, messages=[{"role": "user", "content": prompt}] ) raw = resp.choices[0].message.tool_calls[0].function.arguments # 规则修复 parsed = repair_with_rules(raw, schema) if parsed is not None: return parsed # 模型自修复 repair_prompt = ( "上次模型输出不是合法JSON,错误信息如下:\n" f"{raw}\n---\n{last_error}\n" "请仅输出修正后的JSON对象,不要添加解释。" ) ... raise ValueError("结构化输出失败:重试次数已用完")这里面有几个参数值得细说:
temperature=0.0是必须的。结构化输出容不得“创造性发挥”,温度越高,格式漂移概率越大。我甚至建议在和结构化输出相关的调用上永远锁死0,哪怕牺牲一点点表达丰富性——因为你要的不是丰富,是可靠。
max_retries=3是成本和成功率的平衡点。我统计过,大多数脏输出在第一二次修复后就能救回来,超过三次还救不回来,说明模型状态已经不对了,再重试只是浪费token。这种情况不如结束调用,让上层走降级逻辑。
3.4 解析结果的下游使用与Schema版本管理
Pipeline拿到了Order对象之后,业务侧可以直接读字段了,不再担心类型问题。但这里还有一个容易被忽略的细节:Schema版本的演进。
业务字段是会变的。今天Order有5个字段,下周可能加一个coupon_code。如果你把Schema直接硬编码在代码里,改起来不难;但如果Schema已经作为契约发布给了别的团队,或者存在模型缓存里,改动就要谨慎。我的做法是把Order这类模型和业务代码放在一起,每次改动走正常的代码评审。同时给生成的JSON Schema打一个version字段,方便日志里排查“上游换了Schema,下游老代码还在按旧格式解析”的错位问题。
还有一个更隐蔽的坑:不要把Pydantic模型直接传给模型的parameters,它内部的title等元信息模型不一定乐意接受。我每次都会用model.model_json_schema()先转成标准JSON Schema再传出去,这能减少一部分平台侧的校验警告。
4. 排查速查表:脏输出抢救指南
4.1 症状、根因与对应解法
我把实操中反复遇到的几类问题整理成了一张表,可以贴在工位上备查:
| 现象 | 根因 | 最快解法 | 根治方案 |
|---|---|---|---|
| 输出带```json围栏 | 模型的markdown习惯 | 剥围栏再解析 | 提示词显式禁止 + 使用平台JSON Mode |
| 正文前有“好的”“以下是”等客套 | 助手角色设定过重 | 正则截取首个{到末尾} | 提示词强调“只输出对象” + 工具调用 |
| 尾逗号、单引号 | 模型模仿了脏训练语料 | 正则清理尾逗号 | 接入修复管线,错误反馈喂给模型 |
| 字段值类型不对(如数字写了字符串) | 模型对Schema理解偏差 | 规则层强行转换 | Schema的description里给示例值 |
| 输出被截断 | 上下文过长或token上限不够 | 结果截断处补括号重试 | 压缩上游输入,提升max_tokens |
| 嵌套对象缺失 | 深度生成时模型“忘了” | 降级为默认空值 | 降低Schema复杂度,拆成多轮调用 |
这些方案有个共性:先快速止血,再长期治理。止血靠的是解析层的宽容度和修复规则,治理靠的是Schema设计、平台能力和调用策略的联动。我不建议一上来就搞“万能解析器”去兼容所有脏格式,那只会让下游越来越脏——真正的解法是建立起“不合格就打回重来”的机制,逼着上游交付合格品。
4.2 两个独家“坑”:错误信息要喂回去,重试别带全史
我踩过最深的一个坑,是初版修复器只做“无脑重试”。模型第一次输出烂的,我什么信息都不给它,让它再生成一次,结果它用同样烂的方式再犯一遍——因为对它来说,第一次的输出就是它的“正确答案”范本,它只会微调表述,不会意识到格式不对。
后来我改成把校验异常信息原样拼进重试提示词里,效果立刻不一样。模型看到“Expected string at field order_id, got number”,它确实会主动修正。这个做法让我想起团队里老工程师说的一句话:让模型学会从错误中学习,前提是你把错误给它看。
另一个坑跟对话历史有关。Agent场景里,结构化输出调用往往发生在多轮对话中,历史消息又长又多。如果重试时把整段历史都塞回去,模型会被上下文里的各种闲聊带偏,反而更容易忽略格式要求。我的做法是重试时只保留“当前任务指令 + 错误输出 + 错误信息”三段,构造一个干净的重试上下文。实测下来,重试成功率比带全史高不少,token开销也小得多。
4.3 监控结构化输出质量的三个指标
工程做到后面,拼的不只是能不能解析,而是能不能稳定、低成本地解析。我给所有接入结构化输出管线的模块都加了三个监控指标:
第一个是首次解析成功率,也就是不经过任何修复、直接一次解析通过的比例。这个指标反映的是“提示词+平台能力”这套前置约束的质量。如果这个数字长期低于70%,说明你的提示词或Schema设计有问题,修再多的后面的修复器也是苟延残喘。
第二个是修复后成功率,反映的是修复管线的兜底能力。我见过不少团队把修复器越写越复杂,修复成功率倒是上去了,但底层生成质量一直没改善,这是本末倒置。修复器应该永远只是安全网,不该变成业务主路径。
第三个是平均重试次数和token消耗。结构化输出比普通对话贵,因为它天然要多几次校验和可能的修复。如果你发现某个场景的平均重试次数大于1,说明生成侧质量已经堪忧,需要回去改Schema描述或者增强字段示例,而不是继续加修复规则。
最后分享一点个人心得
做结构化输出工程这一年多,我最大的体会是:别把“模型输出”当成“程序返回值”用,它天生是自由文本,你要做的是用制度和流程把它一步步掰成结构化数据。我现在的默认写法是——写任何Agent业务逻辑之前,先花时间把响应Schema定义到位,字段的格式、约束、描述都写全,然后再去写提示词。因为这个顺序一旦反过来,提示词写完你想再约束输出结构,往往就得返工。
还有一个值得尝试的小技巧:在正式接入业务前,拿20条典型请求跑一遍完整管线,把每次失败的原因归类。你会发现,大多数模型的“个性翻车”其实就那么几种,把这些高频失败样本沉淀成标准修复规则(比如“截取花括号对儿”“清理围栏”“去尾逗号”),你的修复器就能又好又省。等规则库积累到一定程度,你甚至可以给团队写一份《模型输出抢救规范》,让每个新人都能照着处理脏数据——那样,你这份结构化输出工程,就不只是代码层面的工程,而是团队协作层面的方法论了。