Agent 的 JSON 解析崩了三次——拆完 SDK 源码我才搞懂 arguments 为什么是字符串

说真的,我被一个 JSON 字符串折腾了三天。

不是什么复杂的嵌套结构,也不是编码问题,就是 OpenAI 返回的 tool_calls[0].function.arguments------它是个字符串,不是对象。你可能觉得这有什么好说的,JSON.parse 一下不就行了?对,我也是这么想的,直到线上崩了三次。

第一次崩:直接当对象用

最开始写 agent 框架的时候,我想当然地把 arguments 当对象处理了。毕竟你在调试器里看到的响应长这样:

json 复制代码
{
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": {
      "name": "search_products",
      "arguments": "{\"query\": \"手机壳\", \"limit\": 10}"
    }
  }]
}

arguments 的值看起来就像个对象,引号包裹的 JSON 嘛。但它是字符串。字符串!我直接 toolCall.function.arguments.query 这么取的,TypeError 砸脸上的时候我还在想是不是 API 返回了什么奇怪格式。

typescript 复制代码
// 第一版代码------能跑但随时会炸
interface ToolCall {
  id: string;
  function: {
    name: string;
    arguments: Record<string, any>; // 想当然地标成了对象类型
  };
}

async function handleToolCall(toolCall: ToolCall) {
  const { name, arguments: args } = toolCall.function;
  const result = await executeTool(name, args.query, args.limit);
  // TypeError: Cannot read properties of undefined (reading 'query')
  return result;
}

改成 JSON.parse 之后确实好了。但只好了两天。

第二次崩:JSON.parse 自己也崩

第二次崩溃发生在一个流式响应场景。模型在生成 arguments 的过程中 token 被截断了,不是网络问题,是模型输出的 JSON 本身就不完整。你拿到的是 {"query": "手机壳", "limit": 这种半吊子字符串,JSON.parse 直接抛 SyntaxError: Unexpected end of JSON input

我当时的第一反应是加 try-catch,捕获到错误就重试。但重试意味着重新调一次 LLM,一次调用几分钱,一天下来几百次截断,钱包在滴血。而且重试出来的结果还是可能截断,因为触发截断的原因跟 prompt 长度和工具复杂度有关,不是随机事件。

typescript 复制代码
// 第二版------能兜住但很蠢
function parseArguments(raw: string): Record<string, any> | null {
  try {
    return JSON.parse(raw);
  } catch {
    // 重试?放弃?装死?
    console.error('arguments 解析失败:', raw);
    return null;
  }
}

返回 null 之后上层逻辑怎么办?整个 agent 循环就卡在这了。用户看到的是一个永远转圈的 loading,没有任何反馈。我盯着那个 loading 图标看了好久,觉得它在嘲笑我。

第三次崩:模型自作主张加了 markdown

第三次最离谱。模型有时候会在 arguments 里夹带 ```````json```` 标记,返回的不是纯 JSON 而是这样的:

text 复制代码
```json
{"query": "手机壳", "limit": 10}
php 复制代码
`JSON.parse` 解析带 markdown 代码块的字符串?想都别想。我当时坐在工位上盯着日志看了五分钟,脑子里只有一个念头:为什么?为什么 OpenAI 要把 arguments 设计成字符串?如果直接返回对象,这些问题不就都不存在了吗?

带着这个怨念我去翻了 OpenAI Node SDK 的源码。

## 拆 SDK:arguments 的生命周期

翻源码的过程没什么戏剧性。`npm install` 之后直奔 `node_modules/@openai/openai/dist/`,找到 completions 的类型定义文件,arguments 的类型赫然写着 `string`。没有注释解释为什么。

```typescript
// openai/resources/chat/completions.d.ts (简化)
export interface ChatCompletionMessageToolCall {
  id: string;
  type: 'function';
  function: {
    name: string;
    arguments: string;  // 就是 string,不是 Record<string, any>
  };
}

SDK 层面完全不做解析。那到底为什么?

答案藏在流式响应里。OpenAI 的 chat completion API 支持 stream: true,在这种模式下,响应是一个个 chunk 推过来的。每个 chunk 里的 arguments 可能只有几个 token。我翻了 OpenAI 官方文档的流式事件示例,arguments 是这样分片到达的:

text 复制代码
event: response.function_call_arguments.delta
data: {"delta": "{"}

event: response.function_call_arguments.delta
data: {"delta": "\"location\""}

event: response.function_call_arguments.delta
data: {"delta": ":"}

event: response.function_call_arguments.delta
data: {"delta": "\"Paris\""}

event: response.function_call_arguments.delta
data: {"delta": "}"}

event: response.function_call_arguments.done
data: {"arguments": "{\"location\":\"Paris\"}"}

看到没?每个 delta 只是一个 JSON 片段。{""location"":""Paris"}------这些东西单独拿出来都不是合法的 JSON 对象。如果 API 把 arguments 定义为对象类型,在流式模式下就没法逐 chunk 返回,因为一个不完整的 JSON 片段没办法表示成对象。

字符串就没有这个问题。它可以被切成任意片段逐个推送,客户端负责在流结束后拼接再解析。

说白了,这是一个为了流式架构而做的妥协。非流式模式也跟着用同一套数据结构,因为 API 不想在流式和非流式之间维护两套 schema。合理吗?从工程角度讲合理。但从开发者体验来讲,我花了三天才搞明白这回事,文档里就一句 arguments: string 带过,这个感受不太好。

一个能扛住各种妖魔鬼怪的解析管道

搞清楚原因之后,我写了一个解析管道,到目前为止跑了两个月没再崩过。核心思路是四步:提取、宽松解析、修复、校验。

typescript 复制代码
/**
 * 生产级 arguments 解析管道
 * 处理:markdown 包裹 / 不完整 JSON / 尾逗号 / 混合内容
 */
function parseToolArguments(raw: string): Record<string, any> {
  // 第一步:剥离 markdown 代码块标记
  const cleaned = raw
    .replace(/^```(?:json)?\s*/i, '')
    .replace(/\s*```\s*$/, '')
    .trim();

  // 第二步:尝试直接解析
  try {
    return JSON.parse(cleaned);
  } catch {
    // 继续往下走
  }

  // 第三步:提取 JSON 片段(处理模型在 JSON 前后加废话的情况)
  const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
  if (jsonMatch) {
    try {
      return JSON.parse(jsonMatch[0]);
    } catch {
      // 还是不行就进修复阶段
    }
  }

  // 第四步:修复常见格式问题后重试
  const repaired = cleaned
    .replace(/,\s*}/g, '}')   // 尾逗号
    .replace(/,\s*]/g, ']')   // 数组尾逗号
    .replace(/'/g, '"')       // 单引号转双引号
    .replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3'); // 无引号的 key

  try {
    return JSON.parse(repaired);
  } catch {
    throw new Error(`arguments 解析失败,原始内容: ${raw.slice(0, 200)}`);
  }
}

等一下,第四步那个正则替换有个坑我得说。最早我写的是 replace(/(\w+):/g, '"$1":'),看起来没问题是吧?但它会把值里面的冒号也替换掉。比如 {"url": "https://example.com"} 里的 https: 会被搞成 "https":,整个 JSON 就废了。这个 bug 我是在单元测试里发现的,测试用例里有一条 URL 参数,跑完发现 URL 被截断了,调试了半小时才定位到正则头上。

我后来改成了 replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3'),只在左大括号或逗号后面匹配无引号的 key。这个版本稳多了。

但你要问我它能不能覆盖所有情况?当然不能。模型总有办法给你整出新花样。我见过最离谱的一次是模型在 arguments 里写了 null,不是 {"key": null},就是光秃秃一个 null。这种就属于模型本身没搞明白要调什么工具,解析管道再强也救不回来,得从 prompt 层面解决。

流式拼接:容易被忽略的一步

前面说了,流式模式下 arguments 是分片到达的。如果你用流式接口做 agent,必须在流结束后再做一次拼接和解析,而不是每个 chunk 都解析一次。我加了个简单的 accumulator:

typescript 复制代码
async function streamToolCall(
  stream: AsyncIterable<ChatCompletionChunk>
): Promise<{ name: string; args: Record<string, any> }> {
  let argumentsBuffer = '';
  let toolName = '';

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta;
    const toolCall = delta?.tool_calls?.[0];

    if (toolCall?.function?.name) {
      toolName = toolCall.function.name;
    }
    if (toolCall?.function?.arguments) {
      argumentsBuffer += toolCall.function.arguments;
    }
  }

  const args = parseToolArguments(argumentsBuffer);
  return { name: toolName, args };
}

这段代码在雷达鸭的客服 agent 上跑了一段时间,每天大概处理三四百次工具调用,arguments 解析相关的报错从每周十几次降到了零。顺便一提,雷达鸭是我在做的一个 App,收录一人公司的真实赚钱案例,华为应用市场能搜到。客服 agent 负责自动回复用户关于案例分类的问题,用的就是上面这套解析管道。

扯远了

arguments 是字符串这个设计,我现在能理解了,流式架构的约束使然。但这不代表它对开发者友好。如果 SDK 能在非流式模式下自动帮你解析好,或者在文档里把原因写清楚,我那三天就不用白费了。

你要是也在做 agent 开发,建议在工具调用解析这层多花点心思。别像我一样,JSON.parse 一把梭,然后等线上崩了再补。

你遇到过 arguments 解析的坑吗?是哪种奇葩情况?欢迎留言聊聊。


关于作者

老三,10+ 年软件开发经验,软件设计师、人工智能应用工程师。专注鸿蒙应用开发(ArkTS 北向开发)和 Web 前端,业余折腾 AI 自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的技术笔记。

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

相关推荐
Tyler_11 小时前
iOS PlayWright mcp server
前端·ios·ai编程
yangdaxiageo1 小时前
算法时代的“心智置顶“:《功夫女足》如何构建GEO正向飞轮
人工智能·科技·算法·aigc
liuliuqiqirr2 小时前
2026最新6款AI编程工具基础版免费实测合集
ai编程
nopSled2 小时前
AlphaAvatar v0.6.4 架构升级:统一实时感知、RTC 解耦与在线 ENV Memory
人工智能·架构·实时音视频·agent·多模态
我没胡说八道2 小时前
论文排版自动改格式,一键智能排版,减去百分之九十排版问题,实测工具大比拼
人工智能·深度学习·机器学习·aigc·论文
humors2212 小时前
【转帖】关于防范AI编程工具Claude Code安全后门隐患的风险提示
网络·安全·ai编程
西游音月3 小时前
Windows环境下的WSL+Claude Code安装配置
windows·大模型·ai编程·wsl·claude code
chanalbert3 小时前
Agent 工程化指南(二):记忆与规划 — 让 Agent “记得住“且“做得对“
agent·memory·rag·mcp
pany12 小时前
做 AI 友好的开源 Vue3 模板 🌈
前端·vue.js·ai编程