Agent 的 JSON Mode 我弃了——三个项目全翻车,改用 function calling 做结构化输出后稳了
2026/7/13 20:55:43 网站建设 项目流程

我在做的 App 叫雷达鸭,后端是 uniCloud 撑的。最近在重构一个数据汇总 Agent 的时候,又把 JSON Mode 这个坑踩了一遍。这算是第三次了,我觉得有必要写篇文章把这事说清楚。

先扔结论:如果你的 Agent 既要做结构化输出,又要调用外部工具,别用 JSON Mode。不是它不能用,是它跟工具调用一起用的时候,事情会变得很诡异。

项目一:客服 Agent

去年我们做了一个客服自动回复 Agent。需求很简单——用户问一个问题,Agent 分析意图,然后返回一个标准格式的 JSON,里面包含回复内容、推荐商品标签、是否转人工三个字段。

我们一开始用了 OpenAI 的response_format: { type: 'json_object' }。前两周跑得好好的,直到有一天运营同学反馈说后台收到了一堆解析失败的错误。我查日志,发现 LLM 返回的 JSON 里偶尔会出现undefined作为字段值,或者干脆把 markdown 代码块也包进去了。

最离谱的一次,模型返回的 JSON 后面还跟着一大段解释性文字,说为什么这么回复。json_object模式按说应该只返回 JSON,可模型就是会在某些不确定的情况下夹带私货。

我加了后处理——先正则提取 JSON 片段,再解析。这能兜住 90% 的情况,但剩下的 10% 会在凌晨三点把你叫起来。

项目二:数据汇总 Agent

第二个项目就是开头提到的那个数据汇总 Agent。这个 Agent 需要先从数据库拉数据(工具调用),然后生成一份分析报告(结构化输出)。

我们一开始的设计是:第一步用 function calling 查数据库,第二步把结果拼进 prompt,再用 JSON Mode 要求模型输出报告。

问题出在第二步。当第一步返回的数据量比较大时,模型在 JSON Mode 下的输出质量明显下降。字段会漏,嵌套数组的格式会歪,甚至直接返回不完整的 JSON。我怀疑是 JSON Mode 的约束和上下文长度产生了某种冲突——模型既要处理大量输入数据,又要严格遵守输出格式,两者打架。

我试过缩短输入数据,但那样报告的质量又不够。也试过把报告拆成多段生成,但上下文衔接又是一堆问题。那段时间我们每天都在修解析器,而不是在做业务。

项目三:复杂配置生成

第三次是一个页面配置生成模块。这个配置是嵌套三层的数据结构,包含条件判断、数组、映射关系。

JSON Mode 直接不支持这种复杂度的约束。OpenAI 的文档写得明白:json_object只能保证输出是合法 JSON,不保证结构一定符合你的 schema。我们试过用 JSON Schema 约束(response_format: { type: 'json_schema', ... }),但发现了三个问题。

一是兼容性问题。不是所有模型和版本都支持json_schema。我们切换模型的时候,这部分代码要重写。

二是调试困难。当输出不符合 schema 时,你根本不知道模型是在哪个字段上偏离了。错误信息就是一句JSON parse error,你得把原始输出打印出来,肉眼对比。

三是它跟工具调用不能混用。一个请求里,你不能既让模型调用工具,又要求它用 JSON Mode 返回最终结果。至少 OpenAI 的 API 是这样设计的。这意味着如果你的 Agent 需要先用工具查数据再生成报告,你得拆成两次请求,延迟和成本都翻倍。

转向伪工具方案

第三次翻车之后,我们团队开会,有人提了一个想法:既然 function calling 的输出格式那么稳定,为什么不把结构化输出也伪装成一个工具调用?

具体做法就是定义一个"伪工具",比如叫format_response,它的参数 schema 就是你想要的输出结构。然后你把这个工具放进 tools 列表里,在 system prompt 里告诉模型:完成任务后,调用 format_response 提交你的最终结果。

我一开始是怀疑的。这听起来像是一种 hack,不太正经。但试了三天之后,我把之前写的 JSON 解析器全删了。

原因是 function calling 的格式稳定性是由模型训练保证的。tool_calls 的arguments字段虽然是个字符串,但它的结构一致性比 JSON Mode 高得多。而且,function calling 的 schema 是显式声明的,模型偏离 schema 的概率明显低于 JSON Mode 下偏离隐式约束的概率。

真正有用的是,伪工具可以跟真正的工具调用混在一起。模型可以先调用query_database查数据,再调用format_response提交报告,全部在一个请求流里完成。不需要拆两次请求,延迟从 4-6 秒降到 2-3 秒。

代码示例

我们现在的 Agent 核心结构大概长这样。伪工具的定义:

// tools/responseFormatter.tsexportconstformatResponseTool={type:'function'asconst,function:{name:'format_response',description:'将最终结果按指定格式提交。分析完成后必须调用此工具。',parameters:{type:'object',properties:{reply:{type:'string',description:'给用户的回复内容'},tags:{type:'array',items:{type:'string'},description:'关联标签'},transfer:{type:'boolean',description:'是否转人工'},confidence:{type:'number',description:'置信度 0-1'}},required:['reply','transfer']}}};

真正的工具混在一起:

// tools/index.tsexportconstallTools=[queryDatabaseTool,// 真正的数据查询sendNotificationTool,// 真正的消息推送formatResponseTool,// 伪工具:结构化输出];

Agent 执行层的关键逻辑:

// agent/executor.tsinterfaceAgentResult{done:boolean;result?:Record<string,unknown>;}asyncfunctionrunAgent(userInput:string):Promise<AgentResult>{constmessages=[{role:'system'asconst,content:systemPrompt},{role:'user'asconst,content:userInput}];// 循环执行,直到模型调用 format_responsewhile(true){constresponse=awaitllm.chat.completions.create({model:'gpt-4o',messages,tools:allTools,tool_choice:'auto'});constmsg=response.choices[0].message;messages.push(msg);if(msg.tool_calls){for(constcallofmsg.tool_calls){if(call.function.name==='format_response'){// 命中伪工具,解析并返回constargs=JSON.parse(call.function.arguments);return{done:true,result:args};}// 真正的工具执行constresult=awaitexecuteRealTool(call.function.name,call.function.arguments);messages.push({role:'tool'asconst,tool_call_id:call.id,content:JSON.stringify(result)});}}}}

System prompt 里的关键一句:

constsystemPrompt=`你是客服助手。分析用户需求后,先调用相关工具获取数据,完成后必须调用 format_response 提交你的回复。不要直接输出 JSON。`;

两周后的对比

改用伪工具方案跑了两周,对比之前的 JSON Mode,有几个明显的变化。

解析失败率从每周十几次降到零。不是因为我们的解析器变强了,是因为模型输出格式本身就稳了。function calling 的arguments字段在训练时被强化过,很少出现多余的 markdown 包裹或者字段类型错乱。

延迟降了差不多一半。以前查数据和出报告要拆两次请求,现在一次请求里模型自己决定先查后出。省了往返时间,也省了一次 API 调用费用。

调试变简单了。如果输出不符合预期,我直接看 tool_calls 里 format_response 的 arguments 就行。不需要在一堆文本里抠 JSON 片段。

当然,这个方案也不是银弹。如果模型没调用 format_response 就回复了纯文本,你得有兜底的逻辑。我们在代码里加了一个检测:如果一轮循环结束没有 tool_calls,就把模型的回复内容当成reply字段,confidence 强制设为 0.3,然后返回。这个兜底两周触发过一次,原因是 prompt 里忘了强调"必须调用"。

还有一个细节:伪工具的 description 要写得很明确。我们之前写得太短,模型偶尔会在没完成分析的时候就调用 format_response。后来把 description 改成了"分析完成后必须调用此工具提交最终结果",这个问题就消失了。说明描述里的时态词和强制词对模型行为确实有影响。

说实话,我到现在也没想明白为什么 JSON Mode 在简单场景下表现不错,一到复杂场景就频繁翻车。可能是训练数据里的 function calling 样本质量更高,也可能是 JSON Mode 的约束在推理阶段被当成了"建议"而不是"规则"。不管原因是什么,我们的经验是:伪工具方案在实战中更可控。

如果你的 Agent 也在用 JSON Mode 做结构化输出,而且偶尔会被格式解析问题折磨,我建议你试试伪工具方案。至少对我们来说,删掉那堆 JSON 正则解析器的那一天,感觉比发布新功能还爽。

你遇到过类似的问题吗?或者你用过其他更稳的方案?


关于我

我是老三,10 年以上软件开发经验,软件设计师,人工智能应用工程师。目前专注鸿蒙应用开发(ArkTS)和 Web 前端,也在探索 AI 自动化方向。不定期在 CSDN 分享鸿蒙和 AI 相关的技术文章。

本文遵循 MIT 协议,转载请注明出处。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询