说真的,我被一个 JSON 字符串折腾了三天。
不是什么复杂的嵌套结构,也不是编码问题,就是 OpenAI 返回的 tool_calls[0].function.arguments——它是个字符串,不是对象。你可能觉得这有什么好说的,JSON.parse 一下不就行了?对,我也是这么想的,直到线上崩了三次。
第一次崩:直接当对象用
最开始写 agent 框架的时候,我想当然地把 arguments 当对象处理了。毕竟你在调试器里看到的响应长这样:
{
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "search_products",
"arguments": "{\"query\": \"手机壳\", \"limit\": 10}"
}
}]
}
arguments 的值看起来就像个对象,引号包裹的 JSON 嘛。但它是字符串。字符串!我直接 toolCall.function.arguments.query 这么取的,TypeError 砸脸上的时候我还在想是不是 API 返回了什么奇怪格式。
// 第一版代码——能跑但随时会炸
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 长度和工具复杂度有关,不是随机事件。
// 第二版——能兜住但很蠢
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 而是这样的:
```json
{"query": "手机壳", "limit": 10}
`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 是这样分片到达的:
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 带过,这个感受不太好。
一个能扛住各种妖魔鬼怪的解析管道
搞清楚原因之后,我写了一个解析管道,到目前为止跑了两个月没再崩过。核心思路是四步:提取、宽松解析、修复、校验。
/**
* 生产级 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:
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 协议,转载请注明出处。