Agent 的 JSON Mode 我弃了——三个项目全翻车,改用 function calling 做结构化输出后稳了

我在做的 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 协议,转载请注明出处。

相关推荐
科技圈观察1 小时前
2026年好伴AI医疗专用大模型应用梳理与梯队参考
人工智能
jkyy20142 小时前
深耕AI健康医疗数据智库,赋能企业构建主动健康管理新生态
大数据·人工智能·健康医疗
cd_949217212 小时前
3D角色自动绑骨怎么做?用V2Fun完成建模、绑定、动作和导出
人工智能·3d
瑞禧生物tech2 小时前
SH-PEG-Biotin巯基-聚乙二醇-生物素 HS-PEG-Bio 深度解析
人工智能
冬奇Lab3 小时前
MCP 系列(06):MCP vs Function Calling——用数据说话的选型指南
人工智能
冬奇Lab3 小时前
每日一个开源项目(第159篇):Vibe-Trading - 用自然语言做量化研究,AI 驱动的个人交易 Agent
人工智能·开源·资讯
AI大模型-小雄3 小时前
2026 年 7 月国内怎么开通 ChatGPT Pro?5x / 20x 区别、适合人群与避坑指南
人工智能·gpt·chatgpt·ai编程·开发工具·codex·chatgpt pro
快乐非自愿3 小时前
AI低代码破局:数字化降本增效的核心逻辑与商业落地
人工智能·低代码
kyriewen3 小时前
看了微软几万人用AI编程的数据——效率涨24%的代价没人提
前端·ai编程·claude
ctrl_v助手3 小时前
Halcon学习笔记2
人工智能·笔记·学习