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

相关推荐
Setsuna_F_Seiei2 小时前
前端的 AI 学习之路 02 之 Provider 与 Structured Output - 规范化模型输入输出
人工智能·agent·ai编程
JavaPub-rodert3 小时前
王仕宇RAG 实战教程(二):从 0 搭建一个可运行的 RAG 知识库——Embedding、Qdrant 与问答实战
人工智能·embedding
Setsuna_F_Seiei3 小时前
前端的 AI 学习之路 01 之 Agent API 调用 - 和 Agent 的基础对话
前端·人工智能·ai编程
咔咔学姐kk4 小时前
小白程序员必收藏:轻松入门AI Agent开发,大厂校招新风口!
人工智能·深度学习·ai·程序员·大模型·就业·大模型学习
2601_967097224 小时前
园区巡检机器人推荐:4项评估维度与主流产品横评
大数据·人工智能·信息可视化
stuartevil4 小时前
零基础怎么用AI文生漫剧做出一条完整视频
人工智能·音视频
极客猴子5 小时前
能提取抖音视频文案的APP推荐:短视频文案工具合集
人工智能·智能手机·音视频·语音识别
sel_95 小时前
【强化学习】Hands-on Modern RL项目实践|OPD 算法完整解析
人工智能·深度学习·算法·机器学习·语言模型
xushichang123_5 小时前
Agentic AI 应用 Token 开销居高不下,可借助哪些云平台与架构实现成本管控?
人工智能
世岩清上5 小时前
新能源宣传片怎么拍?如何用画面讲好你的绿色能源故事
大数据·人工智能·能源·宣传片·宣传片拍摄