我在做的 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 核心结构大概长这样。伪工具的定义:
typescript
// tools/responseFormatter.ts
export const formatResponseTool = {
type: 'function' as const,
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']
}
}
};
真正的工具混在一起:
typescript
// tools/index.ts
export const allTools = [
queryDatabaseTool, // 真正的数据查询
sendNotificationTool, // 真正的消息推送
formatResponseTool, // 伪工具:结构化输出
];
Agent 执行层的关键逻辑:
typescript
// agent/executor.ts
interface AgentResult {
done: boolean;
result?: Record<string, unknown>;
}
async function runAgent(userInput: string): Promise<AgentResult> {
const messages = [
{ role: 'system' as const, content: systemPrompt },
{ role: 'user' as const, content: userInput }
];
// 循环执行,直到模型调用 format_response
while (true) {
const response = await llm.chat.completions.create({
model: 'gpt-4o',
messages,
tools: allTools,
tool_choice: 'auto'
});
const msg = response.choices[0].message;
messages.push(msg);
if (msg.tool_calls) {
for (const call of msg.tool_calls) {
if (call.function.name === 'format_response') {
// 命中伪工具,解析并返回
const args = JSON.parse(call.function.arguments);
return { done: true, result: args };
}
// 真正的工具执行
const result = await executeRealTool(
call.function.name,
call.function.arguments
);
messages.push({
role: 'tool' as const,
tool_call_id: call.id,
content: JSON.stringify(result)
});
}
}
}
}
System prompt 里的关键一句:
typescript
const systemPrompt = `你是客服助手。分析用户需求后,先调用相关工具获取数据,完成后必须调用 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 协议,转载请注明出处。