
大模型的一次响应往往不止一个工具调用,而工具的参数又是分片、逐步抵达的。系统如何一边把生成过程展示给用户,一边在参数就绪时就尽早执行工具,同时保证结果不乱、对话还能继续?这背后是一套兼顾"展示"与"执行"两条通道的工程实现。
下面用一个购物场景来类比。你在一家 AI 导购里问"帮我把两件商品加入购物车,再算一下总价"。模型在"说话"的过程中,其实已经在生成几个动作的参数:加入 A、加入 B、计算总价。理想的做法是:参数一凑齐就立刻去执行,而不是等模型把整句话说完再统一动手------这样用户能更快看到结果。但提前动手又带来风险:动作是否执行重复、结果顺序是否错乱、对话记录还能不能接下去。
01 先区分三个时间点
一个工具调用从"出现"到"完成",至少经历三个时间点。
-
开始生成:流里出现 tool_use 块,此时只知道工具名和调用 ID,参数还不完整。
-
参数生成完毕:收到该块的结束标记,这时才能把缓冲区当作完整 JSON 解析并执行。
-
工具执行完毕:得到结果回传给模型,模型才能据此继续下一轮推理。
这套实现的入口在 StreamedRound.relay(),执行任务由 EagerDispatcher 管理。核心思路是:在第 2 个时间点就启动工具,而不是等整个模型响应生成完。
02 从模型请求到工具参数
编排器先构造带工具定义的请求,再调用流式接口。购物端与商家端的编排器使用同一套处理方式。
模型返回的不是一次性完整 JSON,而是一连串事件。解析器按内容块的 index 记录状态,把参数一点点拼起来:
-
块开始:记录工具名、调用 ID,创建参数缓冲区。
-
增量:把 partial_json 追加到对应工具的缓冲区。
-
块结束:参数写完,尝试解析完整 JSON。
这里有个容易混淆的点:解析器是逐个处理流事件的,但工具任务可以并发运行。所谓"并行",主要是异步任务在时间上的重叠,而不是开多个线程同时解析同一条模型流。
项目还区分了两种 JSON 解析,各自用途不能混用:
-
执行解析:用 json.loads() 读取完整缓冲区,要求结果是对象。只有它成功,工具才可能执行。
-
展示解析:对支持渐进式卡片的工具,可临时补齐未闭合的括号、略过未写完的字符串,用于生成界面预览。
两者绝不能混用:容错解析出来的半成品只能展示,不能触发有副作用的操作。最终卡片也会重新校验参数、补充服务端数据,再发出正式的 UI 事件。这好比菜还没炒熟,可以先让顾客看一眼"锅里在冒热气",但绝不能把半生不熟的菜端上桌。
03 为什么能提前执行多个工具
收到某个工具的块结束信号后,relay() 调用 dispatcher.dispatch()。后者通过 asyncio.ensure_future() 创建执行任务,并以调用 ID 为键保存。
假设模型一轮生成了 A、B 两个工具调用,时间线大致是这样:
-
模型生成 A 的参数 → A 的块关闭 → 启动 A。
-
模型继续生成 B 的参数 → B 的块关闭 → 启动 B。
-
模型响应结束 → 等待 A、B 全部完成 → 回填结果。
于是两段耗时可以被重叠起来:工具 A 的执行与模型生成后续内容重叠;工具 A、B 的执行彼此重叠。就像流水线上提前开工:上一件还在加工,下一件的图纸已经送进来。
流结束后,编排器以模型最终消息里的 tool_use 列表为准。EagerDispatcher.collect() 对已经启动的调用复用其任务,对还没启动的调用补充执行,再用 asyncio.gather() 等待全部结果。
这里用调用 ID 去重,解决了"流中提前执行一次、读最终消息后又执行一次"的重复风险。gather() 的返回列表与传入列表同序,所以结果虽然可能乱序完成,但发出的结果事件和写入对话的结果仍按模型调用顺序排列。
04 结果怎样回到模型和前端
工具统一由 BaseToolExecutor.execute() 分派。它负责选择普通业务 handler、展示工具或委托工具,并把参数错误和执行异常转换成统一的 ToolOutcome。
一个 ToolOutcome 有两类产物:
-
result_text:作为 tool_result 写入对话,供下一轮模型调用读取。
-
events:例如正式卡片 UI、购物车更新,供前端消费。
编排器在一轮工具全部结束后发出结果事件,再把所有 tool_result 作为一条 user 消息追加到模型对话。若还需要模型回答,就进入下一轮模型调用。
前端接收的是 SSE:服务端把 AgentEvent 编码成 event: 与 data: 帧,浏览器逐帧解析。渐进式预览和最终 UI 使用调用 ID 关联,使最终卡片能接替预览卡片。
要注意一个时序细节:tool_call 可以在模型仍在生成时发给前端,但这个项目的常规 tool_result 是统一 join 后再按顺序发出的。不能把"工具提前完成"直接理解成"完成瞬间就向前端发结果"------展示通道可以渐进,结果交付仍然是有序的。
05 并发带来的正确性问题
提前并发只是调度方式,业务约束仍必须在执行层保证。还是购物场景:两个修改购物车的调用可能同轮执行,如果"读取购物车、检查限制、写入购物车"这三步交错进行,检查结果就可能被破坏------比如两个动作各自都以为限额还够,同时写入后总额超限。项目用按 session 划分的 asyncio.Lock 包住这三步,确保同一会话内的修改不会交错。
还要处理不完整输入和中断:
-
参数始终不是有效 JSON:保留已生成的对话内容,给该调用一个错误 tool_result,提示模型重试,但不运行工具。
-
流报错、连接中断:取消已启动但未结束的任务;若对话里留下没有结果的 tool_use,补一个中断结果,保证下一次请求的消息结构有效。
-
服务端工具(如模型侧的网络搜索):记录其块,但不通过本地执行器运行。
06 提炼成设计原则与边界
这套实现可以概括为:逐块确定参数、按调用 ID 提前启动、轮末统一 join、按原顺序回填、由业务层保护共享状态。流式预览是独立的展示通道,执行只接受完整参数。这样既缩短了模型生成与工具执行之间的空等时间,也保住了工具只执行一次、结果有序、对话可继续这三项正确性要求。
最后要说明范围:上述逐块解析和提前调度是仓库的 Messages API 运行时实现。仓库另有 Agent SDK 路径,它把工具注册给 SDK,并收集完整响应。不能把这里的流程说成所有接入方式都自行实现了同一套解析。