055、结构化输出:JSON模式与工具调用
昨晚线上告警,一台边缘网关的Agent任务卡死,日志里反复出现同一个错误:JSONDecodeError: Expecting property name enclosed in double quotes。我盯了几分钟,发现问题不在模型,不在网络,在我自己写的解析逻辑。那个模型明明已经在system prompt里被要求“只输出JSON”,它还是把一段markdown代码块包着的JSON返回了。更讽刺的是,我的代码里只做了json.loads(),没做任何容错处理。这已经不是第一次被模型输出格式坑了。今天想把这几年调结构化输出的经验拆开聊,特别是JSON模式与工具调用这两块,能少走的弯路,咱们尽量别走。
先说结论性的一句话:不要指望模型“记住”你的JSON格式要求,要让它“不得不”输出合法JSON。所谓JSON模式,不是提示词里写一百遍“你是一个AI助手,请用JSON格式回答”,而是通过底层约束,把输出空间限制在合法JSON的token分布里。OpenAI系的response_format: {"type": "json_object"}、Anthropic的structured output、以及各种开源模型跑在vLLM/SGLang上的guided_json,本质都是在解码阶段做约束。理解这一点,你就明白为什么有时候你换了更强的模型,反而输出更不稳定——因为强模型更“聪明”,更敢于在JSON里加注释、加单引号、加换行缩进,而这些在严格JSON解析器里全是非法字符。
我之前在项目里干过一件蠢事:让模型输出一个包含“code”和“message”的JSON,然后把json.loads包在try...except里,失败就重试一次。结果生产环境重试了三轮还是挂,后来看日志才发现,模型每次都返回一样的错误格式,重试根本没用。从那以后,我给自己定了个规矩:解析模型输出,第一层永远用字符串查找来“剥壳”,先找到第一个{和最后一个},截取中间内容再json.loads。这一步能干掉90%的markdown包装问题。别觉得这个手段低端,它能在你还没有引入JSON模式框架时,用最少的改动换来最大的稳定性。
真正的结构化输出,得从约束生成讲起。以vLLM为例,它的guided_json参数接收一个JSON Schema,解码时强制模型按Schema的token序列生成。你在代码里传入{"type": "object", "properties": {"action": {"type": "string"}, "params": {"type": "object"}}, "required": ["action", "params"]},那么模型就算再想自由发挥,它生成完"action"的字符串值后,下一个token也只能是逗号或},绝不可能是其他字符。这种硬约束下的输出,直接json.loads都不会报错。另一个常用框架是Outlines,它支持正则约束,如果你只需要一个数字或枚举值,用choices直接限制比JSON更轻量。我这里踩过坑:一开始对所有函数参数都套大JSON Schema,结果某些简单参数让模型生成一个动作名,根本不需要JSON封装,直接用Outlines的regex限定字母数字和下划线,速度快了三倍,还省token。
不过JSON模式只是第一步。工具调用,本质上是把模型的思路变成一个可执行的动作序列。你在API里看到tools参数,传一个函数定义列表,模型返回的不是JSON,而是一个结构化的“tool_call”对象。这个对象里通常包含name、arguments以及一个工具调用ID。这里有个关键点,工具调用的arguments本身就是字符串化的JSON,而且框架层默认不会帮你解析。很多新人把arguments当成字典直接索引,必然报TypeError。我习惯拿到它之后第一时间json.loads,并且用type检查每个参数的类型。模型在工具调用时也会犯错,比如你定义datetime类型,它可能传成字符串,你必须在函数入口做一层强制转换。别相信模型,它只是一个概率分布,不是数据库。
工具调用还有一个隐藏坑:并发工具调用。现在的模型支持一次返回多个tool_call,比如你需要查天气和查日历,模型会在一个回复里同时给出两个调用。如果你用OpenAI的Python SDK,它的tool_calls是一个列表,不是单个对象。我见过同事写response.choices[0].message.tool_calls[0],然后假设永远只有一个工具,最后在Agent任务里莫名丢失一半的调用。正确做法是循环遍历tool_calls,把每个调用塞进一个异步任务池,等所有结果都返回后再拼成一个消息列表回传给模型。回传时注意每个工具结果必须对应正确的tool_call_id,否则模型会混淆哪条结果属于哪个调用,这是工具调用状态机最容易出错的地方。
再说说纯文本模型怎么实现类似JSON模式。开源社区有不少项目给Llama、Mistral这类模型加“function calling”微调,但如果你不想微调,也可以自己构造一个格式极简的指令:让模型用<tool_call>和</tool_call>标签包裹参数,然后你用正则提取。这种方式牺牲了一点规范,换来了模型兼容性。我在跑本地小模型时经常这么干,因为7B模型对严格的JSON Schema适应能力差,稍微给点自由度反而输出更稳定。但要注意,这种自由格式必须配合“终止词”设置——你可以在生成配置里传入stop=参数,告诉采样器一旦生成</tool_call>就停止,防止模型继续吐无关内容。这个细节能省下大量解析后处理工作。
另一个容易忽略的是温度参数。JSON模式下,temperature建议直接设成0,或者至少0.2以下。采样温度越高,模型越可能产生违反Schema的低概率token,虽然被约束层拦截,但会导致生成过程反复尝试,速度变慢,极端情况还会触发约束器的死循环。我遇到过vLLM在temperature=0.8时,某个长JSON生成耗时超过30秒,降到0之后瞬间恢复。你可能会问,JSON模式不是硬约束吗?为什么温度还有影响?因为约束器在很多实现里是“按步采样时屏蔽非法token”,但模型对下一个合法token的置信度分布还是会受温度影响,置信度低时容易出现反复重采样,或者生成无效分支后被迫回溯。所以别把JSON模式当成万能药,该调的超参还得调。
聊聊生产环境的错误处理设计。我给自己项目写了一个三阶段解析器:第一阶段,尝试直接json.loads;第二阶段,如果失败,剥掉所有markdown代码块标记,找到JSON边界再解析;第三阶段,如果还失败,调用一个“修复模型”,把原始文本和期望的JSON Schema发给一个更强或更便宜的模型,让它纠正输出格式。这个三阶段机制上线后,结构化输出成功率从92%提升到99.5%。剩下那0.5%,我选择直接让Agent报错并记录原始输出,而不是无限重试。这里有个血泪教训:千万别写while retry < 5这种循环,因为模型在同样的输入下大概率生成同样的错误输出,重试五次纯粹浪费时间和钱。不如在第一次失败后,就把错误输出作为负面示例拼到新的prompt里,告诉模型“上一步你错了,请参考这个标准格式”,这样第二次生成的正确率会明显提高。
提示词本身也要设计。我给模型写JSON格式要求时,不会只给一个Schema,而是给一个“正例+反例”。正例是期望的输出,反例是常见的错误格式。比如明明要求双引号,反例里给一段单引号的JSON,并标注“这是错误示例”。模型对示例比对规则更敏感,特别是小模型。同时,我会在system prompt里声明“你只能输出一个JSON对象,不要包含任何解释文字”,然后强制开发时不把这句话省略。有人觉得这句话太啰嗦,但实际测试中,加了这句之后,模型直接输出JSON文本(而不是markdown代码块)的概率大幅提升。虽然JSON模式有硬约束,但提示词的作用是减少模型生成“多余字段”的倾向,比如它可能自发加一个"thought": "..."字段,这在你的Schema里没定义,某些严格校验器会直接拒绝。
工具调用与JSON模式结合时,我的实践是把工具定义也纳入JSON Schema的一部分。什么意思?不要只给模型一个tools列表,而是同时给它一个“总控模式”:一个包含"tool"和"input"的大JSON对象,其中"tool"用enum限定,"input"是一个object,其属性根据"tool"的不同而动态变化。这种动态约束用普通的JSON Schema表达不出来,得用条件子Schema,比如anyOf加if-then-else。我在vLLM里实验过,动态约束能显著减少模型选择不存在的工具参数。但这个方案实现复杂,如果模型支持原生tools,还是直接用原生tools更省事。
最后,我得提醒你,JSON模式也好,工具调用也罢,都是让Agent“说人话”和“干活”之间的一座桥。但桥本身不是终点。我见过太多人把精力花在调试JSON解析上,却忽略了Agent真正的工作是“理解意图-调用工具-总结结果”。结构化输出只是保证这个过程不因格式歧义而崩溃。所以我的建议是:初期用最简单的方式跑通全链路,哪怕解析代码土一点,比如先截取大括号再解析,先接受工具参数全是字符串然后手动cast,也不要一开始就上重型框架。等你的Agent逻辑稳定了,再逐步把解析层换成严格的JSON Schema约束。这就像写C语言时先不要用宏,先把函数写出来,跑通再优化性能。我们的目标是让Agent干活,不是让代码看起来高级。
总之,遇到结构化输出问题,先检查你的约束是“软提示”还是“硬约束”。软提示只能改善,硬约束才能保证。然后是工具调用的ID关联和参数解析,这是状态机的核心。最后是错误处理,别死磕重试,用反馈循环让模型自己纠错。技术会变,模型会升级,但这个思路,还能用很久。