ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

055、结构化输出:JSON模式与工具调用

055、结构化输出:JSON模式与工具调用 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在temperature0.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关联和参数解析这是状态机的核心。最后是错误处理别死磕重试用反馈循环让模型自己纠错。技术会变模型会升级但这个思路还能用很久。
返回列表