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

发布时间:2026/7/12 9:22:57

Agent 的 JSON 解析崩了三次——拆完 SDK 源码我才搞懂 arguments 为什么是字符串 说真的我被一个 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 返回了什么奇怪格式。// 第一版代码——能跑但随时会炸interfaceToolCall{id:string;function:{name:string;arguments:Recordstring,any;// 想当然地标成了对象类型};}asyncfunctionhandleToolCall(toolCall:ToolCall){const{name,arguments:args}toolCall.function;constresultawaitexecuteTool(name,args.query,args.limit);// TypeError: Cannot read properties of undefined (reading query)returnresult;}改成JSON.parse之后确实好了。但只好了两天。第二次崩JSON.parse 自己也崩第二次崩溃发生在一个流式响应场景。模型在生成 arguments 的过程中 token 被截断了不是网络问题是模型输出的 JSON 本身就不完整。你拿到的是{query: 手机壳, limit:这种半吊子字符串JSON.parse直接抛SyntaxError: Unexpected end of JSON input。我当时的第一反应是加 try-catch捕获到错误就重试。但重试意味着重新调一次 LLM一次调用几分钱一天下来几百次截断钱包在滴血。而且重试出来的结果还是可能截断因为触发截断的原因跟 prompt 长度和工具复杂度有关不是随机事件。// 第二版——能兜住但很蠢functionparseArguments(raw:string):Recordstring,any|null{try{returnJSON.parse(raw);}catch{// 重试放弃装死console.error(arguments 解析失败:,raw);returnnull;}}返回 null 之后上层逻辑怎么办整个 agent 循环就卡在这了。用户看到的是一个永远转圈的 loading没有任何反馈。我盯着那个 loading 图标看了好久觉得它在嘲笑我。第三次崩模型自作主张加了 markdown第三次最离谱。模型有时候会在 arguments 里夹带json标记返回的不是纯 JSON 而是这样的{query:手机壳,limit:10}JSON.parse解析带 markdown 代码块的字符串想都别想。我当时坐在工位上盯着日志看了五分钟脑子里只有一个念头为什么为什么 OpenAI 要把 arguments 设计成字符串如果直接返回对象这些问题不就都不存在了吗带着这个怨念我去翻了 OpenAI Node SDK 的源码。拆 SDKarguments 的生命周期翻源码的过程没什么戏剧性。npm install之后直奔node_modules/openai/openai/dist/找到 completions 的类型定义文件arguments 的类型赫然写着string。没有注释解释为什么。// openai/resources/chat/completions.d.ts (简化)exportinterfaceChatCompletionMessageToolCall{id:string;type:function;function:{name:string;arguments:string;// 就是 string不是 Recordstring, 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 / 尾逗号 / 混合内容 */functionparseToolArguments(raw:string):Recordstring,any{// 第一步剥离 markdown 代码块标记constcleanedraw.replace(/^(?:json)?\s*/i,).replace(/\s*\s*$/,).trim();// 第二步尝试直接解析try{returnJSON.parse(cleaned);}catch{// 继续往下走}// 第三步提取 JSON 片段处理模型在 JSON 前后加废话的情况constjsonMatchcleaned.match(/\{[\s\S]*\}/);if(jsonMatch){try{returnJSON.parse(jsonMatch[0]);}catch{// 还是不行就进修复阶段}}// 第四步修复常见格式问题后重试constrepairedcleaned.replace(/,\s*}/g,})// 尾逗号.replace(/,\s*]/g,])// 数组尾逗号.replace(//g,)// 单引号转双引号.replace(/([{,]\s*)(\w)(\s*:)/g,$1$2$3);// 无引号的 keytry{returnJSON.parse(repaired);}catch{thrownewError(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 都解析一次。我加了个简单的 accumulatorasyncfunctionstreamToolCall(stream:AsyncIterableChatCompletionChunk):Promise{name:string;args:Recordstring,any}{letargumentsBuffer;lettoolName;forawait(constchunkofstream){constdeltachunk.choices[0]?.delta;consttoolCalldelta?.tool_calls?.[0];if(toolCall?.function?.name){toolNametoolCall.function.name;}if(toolCall?.function?.arguments){argumentsBuffertoolCall.function.arguments;}}constargsparseToolArguments(argumentsBuffer);return{name:toolName,args};}这段代码在雷达鸭的客服 agent 上跑了一段时间每天大概处理三四百次工具调用arguments 解析相关的报错从每周十几次降到了零。顺便一提雷达鸭是我在做的一个 App收录一人公司的真实赚钱案例华为应用市场能搜到。客服 agent 负责自动回复用户关于案例分类的问题用的就是上面这套解析管道。扯远了arguments是字符串这个设计我现在能理解了流式架构的约束使然。但这不代表它对开发者友好。如果 SDK 能在非流式模式下自动帮你解析好或者在文档里把原因写清楚我那三天就不用白费了。你要是也在做 agent 开发建议在工具调用解析这层多花点心思。别像我一样JSON.parse一把梭然后等线上崩了再补。你遇到过 arguments 解析的坑吗是哪种奇葩情况欢迎留言聊聊。关于作者老三10 年软件开发经验软件设计师、人工智能应用工程师。专注鸿蒙应用开发ArkTS 北向开发和 Web 前端业余折腾 AI 自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的技术笔记。本文遵循 MIT 协议转载请注明出处。

相关新闻