当前位置: 首页 > news >正文

一步发多个工具:多 tool_call 的格式

系列第四篇,源码密集篇。经典 ReAct 一次只能做一个动作,而函数调用式可以一步同时发多个工具。这篇看这些工具调用的流式碎片如何靠index归位、如何并发执行、如何回填历史。源码来自 LoopAgent ⭐。

为什么要一步多工具

回到登录超时的例子。有时候模型的最优策略是同时搜两个关键词:“login” 和 “session” 分头查,不用等第一个搜完再搜第二个。经典文本范式的Action:天生一次一个,而函数调用式的tool_calls本质是一个数组,天然支持一步多个。

多个 tool_call 的格式,要分四个层面看——它们是同一批数据在不同阶段的形态。


层面一:线上流式格式(wire format)

模型不会一次性吐出完整的多个 tool_call,而是逐字符流式下发。LoopAgent 把每个流片段抽象成toolCallDelta事件(types.ts:65-71):

|{type:"toolCallDelta";index:number;// ★ 关键:第几个 tool_call(0,1,2...)id?:string;// 调用 id,只在该 call 的首片段出现name?:string;// 函数名,通常只在首片段出现argumentsDelta?:string;// JSON 参数的一小段,会分多次到达}

✶ Insight ─────────────────────────────────────
index是区分多个 tool_call 的唯一凭据。底层 SSE 流把 N 个 tool_call 的碎片交错发下来——index:0的名字、index:1的名字、index:0的参数片段、index:1的参数片段……全靠index才能把碎片归位到正确的那个调用。这是 OpenAI Chat Completions 流式tool_calls协议的标准设计。
─────────────────────────────────────────────────

假设模型决定同时搜 “login” 和 “session”,实际到达的事件流大致这样(简化):

{ type:"toolCallDelta", index:0, id:"call_a", name:"explore_code" } { type:"toolCallDelta", index:0, argumentsDelta:"{\"query\":" } { type:"toolCallDelta", index:1, id:"call_b", name:"explore_code" } ← 第二个开始交错进来 { type:"toolCallDelta", index:0, argumentsDelta:"\"login\"}" } { type:"toolCallDelta", index:1, argumentsDelta:"{\"query\":\"session\"}" } { type:"finishReason", reason:"tool_calls" } ← 收尾信号

注意index:0index:1的碎片是穿插到达的。没有index,你根本没法把"login"}拼回给 call_a 而不是 call_b。


层面二:组装态(按 index 聚合的 Map)

openAiReactModelTurn.ts:25,52-58 用一个Map<number, PendingToolCall>index累积、拼接:

constpendingCalls=newMap<number,PendingToolCall>();// ...constpending=pendingCalls.get(event.index)??{name:"",arguments:""};pendingCalls.set(event.index,{id:event.id??pending.id,// id 取首次出现的name:pending.name+(event.name??""),// 名字累加拼接arguments:pending.arguments+(event.argumentsDelta??""),// 参数字符串累加});

上面那串事件跑完,pendingCalls变成:

{ 0 => { id:"call_a", name:"explore_code", arguments:'{"query":"login"}' }, 1 => { id:"call_b", name:"explore_code", arguments:'{"query":"session"}' } }

arguments此刻还是字符串(拼出来的 JSON 文本),尚未解析。


层面三:结构化态(runner 拿到的形态)

finishReason === "tool_calls"pendingCalls.size > 0,于是走createToolRequests(openAiReactModelTurn.ts:100-145)。它按 index 排序遍历,产出两份并行的数组

先看它的完整性校验(openAiReactModelTurn.ts:105-116):

for(const[,pending]of[...pendingCalls.entries()].sort(([l],[r])=>l-r)){if(!pending.id)thrownewError("Tool call did not include an id");if(!pending.name)thrownewError(`Tool call${pending.id}did not include a name`);if(ids.has(pending.id))thrownewError(`Duplicate tool call id:${pending.id}`);ids.add(pending.id);letinput:unknown;letparseError:string|undefined;try{input=JSON.parse(pending.arguments);// ← 每个 call 各自解析}catch{parseError=`Invalid JSON arguments for tool${pending.name}`;}// ...}

id、缺nameid重复都会throw,保证下发的每个 call 干净可执行。而 JSON 解析失败只影响该 call 自己(记一个parseError),不牵连兄弟 call。

产出的两份数组:

ModelToolCall[]——存进消息历史用(types.ts:12-19):

[{id:"call_a",type:"function",function:{name:"explore_code",arguments:'{"query":"login"}'}},{id:"call_b",type:"function",function:{name:"explore_code",arguments:'{"query":"session"}'}}]

arguments保持字符串——这是协议原样。

ReactAgentToolRequest[]——runner 执行用(reactTypes.ts:24-30):

[{id:"call_a",name:"explore_code",rawArguments:'{"query":"login"}',input:{query:"login"}},{id:"call_b",name:"explore_code",rawArguments:'{"query":"session"}',input:{query:"session"}}]

inputJSON.parse后的对象,runner 直接拿来用。

✶ Insight ─────────────────────────────────────
这里有个刻意的双份设计:ModelToolCall(带type:"function"arguments为字符串)要原样回写进 assistant 消息历史,下一轮发给模型时它才认得自己上次调了什么,必须符合协议格式;ReactAgentToolRequest(带解析后的input)是给 runner本地执行用的。同一次调用,一个面向"和模型对话的协议",一个面向"本地工具执行",职责分离。
─────────────────────────────────────────────────

最终返回给 runner 的对象(openAiReactModelTurn.ts:139-144):

return{kind:"toolRequests",...(reasoning?{reasoning}:{}),assistantMessage:{role:"assistant",content:"",toolCalls},// ← 含完整 ModelToolCall[]requests,// ← 两个待执行请求};

层面四:runner 如何消费这批多 call

回到 reactAgentRunner.ts:184-266。

① 一条 assistant 消息挂 N 个 call

messages.push(result.assistantMessage);// 一条 assistant,携带 [call_a, call_b] 两个 call

② 分批,并发安全的并行跑(reactAgentRunner.ts:229-231):

constoutcomes=batch.concurrent?awaitPromise.all(batch.requests.map(({request})=>invoke(request)))// 并行:[awaitinvoke(batch.requests[0]!.request)];// 串行

是否可并发由工具自己声明——ReactAgentToolisConcurrencySafe(reactTypes.ts:58)。两个explore_code都是只读搜索,可以并行;但如果其中一个是apply_edit(要写文件),就会被排到串行批次里,避免并发写冲突。

✶ Insight ─────────────────────────────────────
这是函数调用式相对经典范式一个"白捡"的性能红利:"同时 grep 三个关键词"可以一轮打完,墙钟时间 = 最慢那个工具的耗时,而不是三个之和。文本标签范式要实现这个,得自己发明多动作的文本语法并解析,又把可靠性拉回坑里(见第 02 篇)。
─────────────────────────────────────────────────

③ 每个 call 各自回填一条 tool 消息(reactAgentRunner.ts:237-266):

for(const[index,{request,call}]ofbatch.requests.entries()){constoutcome=outcomes[index]!;// ... 更新记分板、发前端事件 ...messages.push({role:"tool",requestId:request.id,// ← 靠 id 对应回各自的 callname:request.name,content:outcome.content,});}

回填后,历史里的形态是:

assistant(toolCalls: [call_a, call_b]) ← 一条 assistant,两个 call tool(requestId:"call_a", content:"login 搜索结果...") ← 各自一条 tool 消息 tool(requestId:"call_b", content:"session 搜索结果...")

✶ Insight ─────────────────────────────────────
“一条 assistant + N 条 tool” 是多 call 在历史里的标准形态。协议硬性要求:每个 tool_call 都必须有一条toolCallId匹配的 tool 消息回应——少一条,下一轮请求就是非法的(服务端会报"有未回应的 tool_call")。这就是为什么 runner 对批次里每个 call无论成败都要push 一条 tool 消息:成功填结果,失败填"Tool error: ...",一个都不能漏。
─────────────────────────────────────────────────


一张图串起四个层面

wire 层 交错的 toolCallDelta 流 ──靠 index 区分──┐ ▼ 组装层 Map<index, {id, name, arguments}> 按 index 拼接 ▼ 结构层 ModelToolCall[] (协议原样,arguments 为字符串) ReactAgentToolRequest[] (解析出 input,可能带 parseError) ▼ 历史层 assistant(toolCalls:[N个]) + N 条 tool(requestId 对应)

全程没有任何"第一个动作/第二个动作"的文本标签,多 call 的边界完全由indexid这两个协议字段划定。

小结

  • 多个 tool_call 本质是一个数组,经历四层形态:wire 流 → Map 组装 → 双份结构化数组 → 历史消息;
  • index负责在流式碎片交错时把它们归位;id负责在回填结果时把 observation 挂回对应 action;
  • 并发安全的工具可Promise.all并行跑,由工具的isConcurrencySafe声明;
  • 铁律:每个 call 无论成败都要回填一条role:"tool"消息,否则下一轮请求非法。

最后一篇,我们看那些把"能跑的 demo"变成"能用的产品"的工程护栏:重复调用拦截、连续失败熔断、证据门禁、倒计时收尾。


📖 上一篇 → 03 · 慢放一次完整的 ReAct 循环 | 下一篇 → 05 · 让 Agent 不翻车的工程护栏

本文源码来自开源项目LoopAgent⭐ → https://github.com/oi12344/loopagent-vscode

http://www.jsqmd.com/news/1365138/

相关文章:

  • 如何用Python一键备份QQ空间所有历史说说?这个开源工具让你轻松搞定!
  • 【2026-08】全封闭美术高考不错的班怎么选?美术高三全日制集训、联考前强化集训甄选——飞帆美术培训 - 多才菠萝
  • 中国知网CNKI Python爬虫实战:基于关键词批量下载论文摘要
  • 商务高端大气简约工作总结PPT模板116份
  • 2026年南宁茅台回收门店靠谱指南,靠谱渠道一览 - 官方资讯
  • 别盲目追捧杂粮养生,吃错杂粮,反而增加身体负担
  • 2026年软件测试趋势与面试全攻略
  • 重庆重疾险未如实告知肺癌拒赔司法判例解析,投保人如何依法维权 - 铅笔写好字
  • 从 HTTP 请求到 SAP RAP 业务服务,彻底理解 OData 在 SAP 技术栈中的位置
  • 从 REST 到 OData,SAP Gateway 如何把 S/4HANA 业务数据变成标准 Web API
  • 微软商店网络故障排查与修复指南
  • 解锁音乐自由:ncmdump让你的网易云音乐随处可听
  • 外贸营业执照怎么翻译?需要哪些材料?新手避开指南! - 点办通
  • 让 Agent 不翻车的工程护栏
  • VMware虚拟机安装Windows XP MCE:经典系统虚拟化实战指南
  • GEO优化机构十强选型指南:技术交付ROI全维度对比 - 资讯报道
  • 门店拼团秒杀活动用什么工具做?活动工具和经营系统打通才是关键 - FaiscoJeff
  • 基于IEEE 1872标准构建Sora 2与UE5.4的可信AI内容溯源工作流
  • ComfyUI-Manager:让AI绘画节点管理变得简单的全能工具箱
  • 5分钟免费解锁:R3nzSkin国服换肤终极指南
  • CSRF防御机制深度解析:从Token绕过到SameSite Cookie实战
  • Cursor与Docker远程开发环境配置实战指南
  • Linux驱动04—设备树
  • 从 HTTP 资源语义到 SAP 业务对象开放,深入理解 SAP Gateway、REST 与 OData
  • 3a证书含金量全解析:从办理条件到实际用途,一篇搞懂所有疑问! - 慧办好
  • AI+蓝牙RSSI信号定位:从原理到实践,实现近场设备查找
  • Corrosion2靶机渗透测试实战指南
  • Kimi K3 本地部署与 OAI 兼容 API 集成实践指南
  • kys-cpp:用现代C++复刻《金庸群侠传》,实现跨平台运行与MOD开发
  • Unity对象池设计模式:从原理到工业级实现与性能优化