结构化输出:Agent 系统为什么不能只靠 Prompt
你的 Agent 对接了下游系统,但 LLM 返回的 JSON 可能有 30% 是坏的。结构化输出不是让 Prompt 写得更详细,而是在 Token 采样层面物理约束输出格式——这是 Agent 系统从 Demo 走向生产的工程底线。
之前写多 Agent 系统的时候,有件事让我反复怀疑人生。
Agent 调工具→工具返回数据→Agent 再调下一个工具。看起来挺顺的。直到有一天日志里出现了一个熟悉的错误:
JSONDecodeError: Expecting ',' delimiter: line 1 column 234 (char 233)
查了一下,Agent A 返回了一段 JSON,Agent B 去解析它,结果 JSON 里多了一个逗号。就一个逗号,整条链路断了。
你可能会想:在 Prompt 里写清楚「请返回合法 JSON」不就好了?
我试了。还加了三条例子、强调了 no markdown、甚至写了威胁语气。该坏的还是坏,只不过从每天 8 次变成了每天 2 次。
后来我从后端开发的经验里找到了一个类比。
从后端的类型安全说起
你在 Spring Boot 里写一个 Controller,不会这么做:
@PostMapping("/user")
public String createUser(@RequestBody String requestBody) {
// 手动解析字符串,找 name、age、email
}
你肯定写一个 DTO,用 @Valid 做校验,让框架帮你把 JSON 反序列化成类型安全的对象。@NotNull、@Email、@Min(0)——这些注解就是契约,编译器级别保证输入格式。
但到了 Agent 系统里,我们退化了。面对 LLM 的输出,第一反应是「在 Prompt 里写清楚点」。这跟在前端用 alert 做校验一样原始——你连编译器级别的保证都没有,全靠模型心情。
那 Agent 跟 LLM 的边界,本质上就是一个 API 边界。你的下游系统——无论是另一个 Agent、一个数据库、还是一个支付接口——都在等一段结构化的输入。把 LLM 的输出当字符串处理,等于在微服务架构里裸奔 HTTP body 不做序列化。
这事的后果在 Agent 系统里会被放大。单次工具调用失败还能重试,多 Agent 链里一个 JSON 格式错误,可能连锁导致整条决策链中断,而且你查日志才知道断在哪一环。
三种姿势,从弱到强
我摸了几个月才搞清楚,结构化输出这件事有三个层次。
第一层:Prompt 暗示。 在 Prompt 里写「返回 JSON 格式」。这层几乎没有保障,模型可能返回 Markdown 包裹的 JSON、可能 JSON 里有注释、可能直接拒绝回答问题并返回一段散文。如果你的 Agent 接入下游系统,这层只够做 Demo。
第二层:JSON Mode。 OpenAI、Claude、Gemini 都有这个模式。JSON Mode 保证输出是合法 JSON(能 json.loads() 通过),但不保证 Schema 正确。什么意思?你要求 {name: string, age: number},它可能返回 {name: "Alice", age: "unknown"}。JSON 是合法的,但类型不对、字段多一个少一个都不管。
第三层:Structured Outputs(约束解码)。 这是 2024 年各大模型供应商陆续引入的真正有工程意义的方案。原理不是让模型「尽量」遵守格式,而是在 Token 采样阶段就通过语法约束,物理上阻止模型生成不符合 Schema 的 Token。OpenAI 在发布时报告了数据:gpt-4o-2024-08-06 在复杂 JSON Schema 评估中拿到 100% 的符合率,而 gpt-4-0613(纯 Prompt 暗示+JSON Mode)不到 40%。
约束解码的本质,是把格式正确性从概率问题变成确定性问题。
Agent 系统里的实际场景
为什么这事在 Agent 里比在普通 Chat 应用里要命得多?
工具调用。 Agent 调工具的本质就是结构化输出——模型需要返回 {tool: "search", args: {query: "..."}} 这样的结构。如果 args 里的 JSON 格式乱了,工具执行层要么抛异常要么拿到残缺参数。官方推荐的做法是工具定义里加 strict: true,让 Schema 级别的约束穿透到每一次调用。
多 Agent 通信。 跟单 Agent 调工具不同,Agent A 传给 Agent B 的消息通常是复杂的结构化数据——包括上下文摘要、中间结果、状态信号。Agent A 输出格式不稳,Agent B 解析失败,整个协作链就断了。我踩过这个坑之后的做法:在 Agent A 的输出层强约束 Schema,Agent B 的入口层用 Pydantic 做二次校验,双重保险。
流式场景的坑。 如果是流式输出场景(比如用户看到效果逐步生成),结构化输出在流式下会引入一个额外的复杂性:你可能在收到一半的 Token 时就做校验了。虽然流式场景下结构化输出通常先把完整结构攒起来再一次性返回,但一些 Provider 的流式实现会在中间 Chunk 插入不完整 JSON。防御性代码要假设「从 Wire 上拿到的东西不一定是合法 JSON」。
实战建议
我从后端工程的经验出发,总结了几条原则。
Provider 层做一次,应用层再做一次。 优先用 Provider 的原生 Structured Outputs API——这是物理层面的保证。但 Provider 保证的是格式不是内容:Schema 告诉你 age 必须是 0~150 的整数,但不能保证模型填入的年龄是真实的。Pydantic(Python)或 Zod(TypeScript)的二次校验是必须的,用于检查业务语义。
Schema 不是越大越好。 一个 50 个字段的嵌套 Schema 会吃掉模型大量注意力去遵守格式,反而影响内容质量。超过 30 个字段的 Schema,拆成多次调用比一次搞定更稳妥。
Self-Correction 是兜底的好手段。 偶尔的 Schema 违例(字段缺失、类型不对)不值得直接崩溃。把校验错误堆栈喂回模型触发重试,通常一轮微调解决 95% 的格式偏差。
递归结构(Agent 回复可以是一个包含子 Agent 调用的结构)注意 Engine 限制。 基于有限状态机(FSM)的约束解码引擎(如 Outlines)无法处理递归 Schema,必须用 CFG 级别引擎(如 XGrammar)。选错了 Engine,浅层嵌套没问题,深层嵌套静默截断——等你发现时已经深夜了。
核心结论
结构化输出不是一个功能开关,是一个工程契约。
它把 Agent 的输出从「这个模型挺聪明,大部分时候格式是对的」变成了「不符合 Schema 的数据在物理层面就放不出来」。这跟你写 Spring 时用 DTO 做校验是一个道理——不是不相信输入方,是你不给坏数据进系统的机会。
后端工程师转型做 Agent,最容易忽略的不只是 Prompt Engineering,更基础的教训是:一个概率模型的输出,不能直接喂给确定性的系统。 中间那层结构化的契约,就是把概率变成确定性的桥梁。
下次你的 Agent 链路因为 JSON 解析失败断了,别再去改 Prompt 了。去加一层契约。
评论
发表评论