Agent 结构化输出工程:别让下游解析"看起来像 JSON"的自由文本
摘要:当 Agent 开始承接真实业务——抽取、分类、编排、跨系统操作——"模型说了什么"远没有"模型输出的东西能不能被机器可靠地消费"重要。本文从真实开发者的踩坑经历出发,剖析自由文本输出在工程链路中的隐性成本,给出以 JSON Schema 为核心的输出契约设计、校验与带错误反馈的重试流水线的最小实现,并整理一份可直接落地的结构化输出验证方法。全文的核心论点只有一个:"支持结构化输出"是一条可以测量的工程属性,而不是模型介绍页的一句卖点——解析成功率与字段级准确率,必须在你自己的 schema 和数据分布上测出来,而不是轻信自我描述。
一、现象:三个被自由文本坑过的真实场景
先把三个在开发者社区里被反复讲起的真实场景摆在一起。
**场景一:理解偏了的问题,被做得非常完整。**有开发者复盘自己使用编码 Agent 的经历:让 Agent 把一套协作方法整理成分享内容,结果它写满了文件路径、软链和校验,技术细节挑不出错,但真正想讲的是"遇到了什么问题、为什么这样解决、别人具体怎么做"——前面产出的内容基本只能推翻重来。后来这个开发者改了协作方式:让 Agent 在执行前先做一次"问题重构",把"我理解的目标是什么、打算怎么拆"先回传,确认后再动手。注意这个动作的本质:把 Agent 的理解强制压进一个可以被人类快速核对的结构里。自由文本的"我理解了"是没有核对价值的——你没法对一段流畅的复述打勾,但可以对三个字段打勾。
**场景二:记录存了,但模型根本没看到。**另一位开发者复盘自己做 AI 客服的经历:用户上一轮刚说完要退订,隔两条消息 Agent 又礼貌地问"请问您用的哪个套餐"。排查下来发现,会话历史确实存了,但传给模型的上下文是一大段拼接的自由文本,关键意图信号淹没在寒暄和噪声里。这个问题后来不是靠改提示词解决的,而是靠改结构——把"用户意图、当前套餐、已确认事项"从叙述文本里拆出来,作为独立的字段注入上下文。存进去的是结构,读出来的才是结构;中间经过一次"自然语言化"再"再理解",信息就在换手时漏掉了。
场景三:节点之间靠猜的编排。还有开发者拆解了一批工作流编排平台后总结:真正能落地的不是"泛智能",而是把任务拆成可声明、可审查、可追踪的节点流程,而每个节点——不管内部是 LLM、插件还是逻辑判断——本质上都是一个"以输入、输出的组装"的模块。这句话反过来说就是:一旦某个节点的输出形状不受约束,下游所有节点的解析都退化为猜测。编排系统最怕的不是某个节点失败,而是某个节点成功地产出了一段格式漂移的文本,让失败一路静默传递到最后一环。
三个场景,三个不同的环节——人机确认、上下文传递、节点编排——但底层是同一个问题:Agent 与它的协作者(人类、下一个节点、下游程序)之间,缺一份关于"输出长什么样"的契约。
二、自由文本的隐性成本:便宜的是生成,贵的是消费
自由文本的问题不在于模型写不好 JSON——今天的模型写"像 JSON 的东西"已经相当像样了——而在于"像"和"是"之间的差距,全部由下游买单。
把一段 LLM 输出喂给json.loads,常见的死法至少有这么几种:前后多了一句"好的,以下是您要的 JSON",需要剥壳;markdown 代码围栏 ```````json ````有时在有时不在,需要正则兜底;字符串里塞了未转义的换行或引号,解析直接报错;长输出被max_tokens截断,括号永远合不上;要求枚举值,它给你造一个近义的新词;要求数组,它在只有一个元素时"聪明地"省掉方括号。每一种单看都是小问题,合起来意味着一件事:你的解析代码不是逻辑,是玄学。
更隐蔽的成本在下游。第一个消费方写了剥壳正则,第二个消费方写了字段名归一化,第三个消费方写了缺失字段默认值——每个接入这个 Agent 输出的人,都在重新发明一遍防御性解析,而且互相不知道对方的兜底假设。当上游格式悄悄漂移时(比如模型升级后不再输出代码围栏),某个下游的剥壳正则反而开始误伤。这就是自由文本作为接口的本质缺陷:接口的稳定性依赖模型的"心情",而工程需要的是不随心情变化的承诺。
还有一种更贵、更难发现的失败模式:解析成功了,但内容是编的。模型按你的 schema 填满了每个字段——包括那个它根本没有依据的order_id。JSON 合法、字段齐全、类型正确,然后一条错误的工单操作被发往生产系统。格式校验回答的是"这个输出是不是你要求的形状",回答不了"这个输出是不是真的"。这是结构化输出工程里最需要警惕的错觉:schema 通过不等于内容可信,两件事必须分开验证。
值得强调的是,这个问题在 Agent 时代被放大了。一次性问答场景里,一段格式漂移的输出顶多让人复制粘贴时多删几句废话;而在 Agent 链路里,输出是喂给下一个程序的原料,格式问题会在链路中复利。更麻烦的是排查成本:下游报的错距离上游根因可能隔着好几个节点,日志里最后一条栈信息是"unexpected token",真正的病灶却是三步之前那次"看起来成功"的生成。契约的存在,本质上是把排查半径从"整条链路"缩小到"单个校验点"。
三、Schema 即契约:把"希望它输出什么"写成机器可验证的规范
解法并不新鲜,就是把接口设计的成熟纪律搬到 LLM 输出上:先写契约,再谈生成。契约的载体就是 JSON Schema——用机器可验证的方式声明字段、类型、必选项、枚举范围和嵌套结构。
主流模型厂商都已经在 API 层面支持这种约束。OpenAI 的 Structured Outputs 可以在请求里直接挂 schema,让模型的解码过程被约束在合法结构内;Anthropic 的 Tool Use 走的是"工具参数即 schema"的路线——你要模型填的不是一段回答,而是一组符合参数定义的工具调用入参。两者的实现机制不同,但设计立场一致:不要在提示词里"恳求"模型输出 JSON,要在协议层让它只能输出 JSON。
实践中,Python 生态最常见的写法是用 Pydantic 声明数据模型,再交给 Instructor 这类库适配各家 API。一个典型的抽取任务契约长这样:
fromenumimportEnumfrompydanticimportBaseModel,FieldclassIntent(str,Enum):REFUND="refund"# 退款DOWNGRADE="downgrade"# 降套餐CONSULT="consult"# 咨询OTHER="other"classExtractedIntent(BaseModel):"""从一条用户消息中抽取结构化意图。"""intent:Intent=Field(description="用户核心意图,无法判断时必须填 other")plan_name:str|None=Field(default=None,description="涉及的套餐名,用户未提到时必须为 null,禁止猜测",)confidence:float=Field(ge=0,le=1,description="判断置信度;低于 0.6 时上层应转人工",)evidence:str=Field(description="支撑该判断的用户原话片段,作为溯源依据")这份定义里有几个值得较真的细节。evidence字段要求模型给出判断依据的原文片段——这是对抗"编内容"的第一道工事:强迫模型把结论和证据绑定,下游(或人工抽检)可以拿证据回原文核对。plan_name的描述里写明"未提到时必须为 null,禁止猜测"——schema 的description不是注释,是提示词的一部分,约束写在离字段最近的地方最有效。confidence配上ge=0, le=1的范围校验,让"模型自己觉得不确定"变成一个可以被程序消费的信号,而不是淹没在文本里的语气词。
一个常被忽略的原则是:**枚举优于自由字符串,null 优于编造。**凡是取值可列举的,用Enum而不是 string;凡是模型可能没有依据的,允许 null 并写明"禁止猜测"。契约写得越"防模型",下游越不需要防御。
四、从生成到校验:一条带错误反馈的重试流水线
schema 约束解码解决了"格式",但"内容合规"仍然需要显式校验——枚举外的业务规则、跨字段的逻辑一致性、引用的 ID 是否真实存在,这些 schema 表达不了或表达起来很别扭的约束,要放在校验层。完整的流水线是:生成 → 结构校验 → 业务校验 → 通过则交付;失败则把错误信息回传给模型重试 → 超过重试上限则降级。
importjsonfrompydanticimportValidationError MAX_RETRIES=2defextract_with_contract(llm_call,user_msg:str,history:str)->dict|None:prompt=build_prompt(history,user_msg)errors:list[str]=[]forattemptinrange(MAX_RETRIES+1):# 校验失败时,把上一次的错误作为上下文回传,让模型看到自己错在哪raw=llm_call(prompt,feedback="\n".join(errors)iferrorselseNone)try:data=ExtractedIntent.model_validate_json(raw)exceptValidationErrorase:errors=[f"schema error:{m}"formine.errors()]continue# 第二层:schema 管不了的业务校验biz_errors=[]ifdata.intent!=Intent.OTHERandlen(data.evidence)<4:biz_errors.append("intent 非 other 时 evidence 必须引用用户原话")ifdata.confidence<0.6anddata.intent!=Intent.OTHER:biz_errors.append("confidence < 0.6 时 intent 应为 other 并转人工")ifbiz_errors:errors=biz_errorscontinuereturndata.model_dump()returnNone# 降级:转人工队列,绝不把未校验的输出放行这段流水线里有三个设计决策值得展开。
**第一,重试必须携带错误反馈。**校验失败后把ValidationError的具体内容拼进下一轮请求,模型才知道是枚举值写错还是字段缺失。无反馈的裸重试只是重复买彩票——同样的问题大概率复现,白白烧两倍 token。有反馈的重试则把校验器变成了提示词的一部分,一次修复一大类问题。
第二,降级路径要在一开始就设计好。return None不是偷懒,是明确声明:"两次带反馈的重试仍然不合格的输出,宁可走人工,也不能进入下游。“很多系统的实际做法是失败后把原始文本塞给下游"凑合用”——这等于整条结构化流水线在最需要它的时刻失效了。降级不是失败,静默放行才是。
**第三,两层校验的边界要清楚。**schema 层管形状和类型,业务层管语义和规则。把业务规则硬塞进 schema(比如用复杂的oneOf组合表达"intent 非 other 时 evidence 必填")会让契约本身变得难读难维护;反过来,把能进 schema 的类型约束挪到业务层用 if 手写,则是浪费了声明式校验的表达力。一个实用的分界线:能一行描述说清的约束进 schema,需要看上下文或查库的约束进业务层。
整条链路可以用一张图对齐:
自由文本流水线(无契约) ┌────────┐ ┌──────────────────────────────┐ │ LLM │──▶│ 下游:剥壳正则 + 字段名猜测 │ └────────┘ │ + 缺失兜底 + 格式漂移监控 │ │ (每个消费方各写一套,静默失败)│ └──────────────────────────────┘ Schema 契约流水线 ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ LLM │──▶│ 结构校验 │──▶│ 业务校验 │──▶│ 下游消费 │ └────────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │ 失败 │ 失败 ▼ ▼ ┌──────────────────────────┐ │ 错误反馈回传 → 重试(≤N次) │──仍失败──▶ 降级转人工 └──────────────────────────┘上面的流水线里,失败是显式的、带原因的、可计数的;下面的流水线里,失败是隐式的、无声的、散落在各消费方的。这就是契约的价值——它不是让失败消失,而是让失败在正确的位置被看见。
五、常见陷阱:契约落地后的五类新问题
结构化输出不是银弹,落地后通常会撞上这几类问题。
**字段幻觉。**schema 通过了,但字段值是编的:plan_name填了一个用户从没提过的套餐名,order_id填了一个格式正确的假号。对抗手段就是上面提到的evidence强制溯源加抽检核对;对高风险动作(退款、改配置),还要在业务层做一次"字段值是否真实存在"的查询校验——查不到就当校验失败走重试或降级。
**枚举漂移。**业务加了新的意图类型,schema 更新了,但提示词里的说明、评测集里的样本、下游的分支处理没跟上。模型要么把新 case 硬塞进旧枚举,要么触发重试风暴。枚举的每次变更都应该当成一次接口变更来对待:同步文档、同步评测、回归一遍解析成功率。
**截断与嵌套过深。**schema 设计得越"全",单次输出越长,被max_tokens截断的概率越高。实践中宁可拆成两次小抽取,也不要设计一个二十个必填字段的大 schema;列表类字段设置合理的maxItems,超长文本让模型返回摘要加引用而不是全文复制。
**过度结构化。**不是所有输出都该进 schema。让模型在严格的字段框架里做开放推理,质量和灵活性都会受损。合理的分工是:判断和推理给自由文本,结论和事实给结构化字段——先让模型自由分析,再抽一次结构化结论,两段式往往比一步到位的巨型 schema 更稳。
**重试成本失控。**带反馈的重试不是免费的,每次重试都是完整的 token 开销和延迟。重试次数、超时预算、降级阈值要一起设计,并且把"重试率"本身当作一个监控指标——重试率突然抬升,通常意味着上游 schema、模型版本或数据分布发生了变化,这本身就是最有价值的告警信号。
还有一类容易踩的坑藏在工具调用型 Agent里。很多人以为接入了 function calling 就自动获得了结构化输出,这个理解只对了一半:工具入参确实被 schema 约束了,但模型可以自由决定调不调、调几次、调完之后说什么。于是你会看到这样的现象:该调检索工具时它直接凭记忆作答,参数照样合法;同一个问题它连调三次相同的工具,每次入参都合法但重复;工具返回错误后,它不修正入参而是换一个更宽松的工具绕过去。对这些行为,schema 一个也拦不住。解法还是回到契约思维:在编排层约束调用序列的形状——某类意图必须先调某个工具才能进入回答分支、相同入参的重复调用去重、工具失败后的重试次数上限——把"调用行为"本身也变成可校验、可统计的对象,而不是寄希望于模型自觉。
六、如何验证:把"支持结构化输出"变成可测量的数字
最后回到验证问题,这也是本文真正想收束的地方。模型厂商页面上的"100% schema adherence"是在他们的测试集上的数字;你接过 API 的那一刻起,有效的数字只有一个来源:在你自己的 schema、你自己的数据分布上测出来的统计量。
最小可行的验证集不需要复杂工程:从真实业务里抽两三百条覆盖各种难度的样本(正常、边界、对抗、语种混杂),跑完整流水线,然后统计四个数字。首轮解析成功率:不重试直接通过两层校验的比例,反映 schema 与提示词的基础质量;终态成功率:算上带反馈重试后的通过率,反映流水线的兜底能力;字段级准确率:对有标准答案的样本,逐字段比对,特别盯枚举字段和plan_name这类易编造字段;重试率与降级率:衡量成本和不可放行比例,是容量规划与告警的依据。
指标 健康参考 危险信号 ───────────────────────────────────────────────────── 首轮解析成功率 > 90% < 75%(提示词/schema 需返工) 终态成功率 > 98% < 95%(降级太频繁,人工兜底被击穿) 字段级准确率 > 95% 枚举字段出现幻觉值(立即阻断上线) 重试率 < 15% 且平稳 突然抬升(上游或模型版本变了) ─────────────────────────────────────────────────────这张表的意义不在具体阈值——每个业务的成本结构不同——而在它把一句模糊的"我们的结构化输出挺稳定的"翻译成了几个可以被追踪、被归因、被回归的数字。当模型升级、schema 变更、提示词调整时,重跑一遍验证集,前后对比就有答案。
样本的设计比数量更重要。只在"干净输入"上评测,得到的成功率会系统性偏高,因为真实流量永远比验证集脏。至少要专门构造三类对抗样本:格式诱导类——用户消息里本身就包含 JSON、代码块或表格,看模型会不会被带偏着把用户内容当自己的输出;长上下文干扰类——历史轮次里出现过相似的意图表达,看 evidence 会不会引错轮次;语义歧义类——"再便宜点就退了"这种既像议价又像退订倾向的消息,看模型是硬塞进某个枚举还是老老实实降低 confidence。这三类样本的通过率,往往比总体成功率更能预测线上表现。
这恰好是 Agent 工程一贯的原则在输出层的具体化:一个 Agent 系统的真实能力,不取决于它声称支持什么格式,而取决于在你的数据上实测出的解析成功率、字段准确率和降级率——这些属性可测,也应该被测。
总结
回顾一下这条论证链:自由文本输出的便宜体现在生成侧,昂贵体现在每一个下游消费方;三个真实场景——确认靠复述、状态靠拼文本、节点靠猜格式——共享同一个病根,即缺少输出契约。解法是把接口设计的纪律搬过来:用 JSON Schema 声明契约,用约束解码保证形状,用两层校验区分"格式对"和"内容真",用带错误反馈的重试控制成本,用显式降级守住底线。落地之后仍有五类陷阱要防:字段幻觉、枚举漂移、截断、过度结构化和重试失控。
而这一切的最终裁判不是 demo——demo 只会展示跑通的那一次。把结构化输出当成一条工程属性,去统计首轮成功率、终态成功率、字段准确率、重试率和降级率,让每一次模型或契约的变更都有回归数字可依。当你能拿出这份验证记录时,"模型输出能不能接入生产"这个问题,才第一次从玄学变成了工程。
关于 Deep Skill Finder
本文讨论的"输出契约与验证",只是 Agent 工程众多"声称与实际之间隔着验证环节"的缩影。Deep Skill Finder 是一个基于真实用户语料的 Skill 发现与选型工具:它不罗列星标排行,而是把社区里被反复验证过的使用经验沉淀成检索与匹配能力,帮你回答"这个 Skill/框架在我的场景里到底可不可信"。如果你在读完本文后想系统评估手头的 Agent 工具链,不妨先从拆解几个经过实战验证的成熟案例开始——具体的获取方式与使用入口,可以在评论区留言或私信交流。