KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
08. 多个工具和流式调用怎样不乱? — keel 龙骨
到目前为止,一轮模型响应里只提过一个工具请求。真实场景里经常一次提两三个——而这两个之外的问题,教科书上很少写:「并发」和「流式」都改变了结果的到达方式,顺序不再可信。
到目前为止,一轮模型响应里只提过一个工具请求。真实场景里经常一次提两三个——而这两个之外的问题,教科书上很少写:「并发」和「流式」都改变了结果的到达方式,顺序不再可信。
现场:一次回去两个结果,全错位了
模型在同一轮里请求两次 lookup_incident,分别查 INC-001 和 INC-002。实现者为了让速度快一点,把它改成了并发:
results = await asyncio.gather(*(run_one(call) for call in calls))
for call, result in zip(calls, results): # 按位置配对
...
当天下午线上出现这样的回答:「INC-002 是登录接口 502,INC-001 是报表变慢」——两个事件的事实对调了。事后查下来原因是:
zip(calls, results)假设结果顺序等于请求顺序;- 有一个调用中途失败抛了异常,
gather把其余结果一起取消,某次实现里又用了return_exceptions=True,于是列表里混着Exception对象,位置配对彻底失效。
第一次发病甚至没用到并发——只要有一次请求中途被跳过(比如被策略拒了没走到执行),位置配对就已经错了。错位的根因不是并发,是「用位置当主键」。
哪些请求不能并发
| 情形 | 处理 | 理由 |
|---|---|---|
| 相互独立的只读查询 | 可以并发 | 无副作用,失败可重试 |
| 后一步依赖前一步的结果 | 必须串行 | 参数还没算出来 |
| 修改同一资源 | 串行或加互斥 | 竞态会互相覆盖 |
| 需要审批才能执行 | 先暂停 | 审批语义上不是「抢跑」 |
| 无法回滚的动作 | 谨慎,通常串行 | 出错后没有退路 |
判断是否并发是 Harness / Executor 的职责。看到模型一次提了三个请求就自动并发,等于把执行语义交给了模型。本课程的 ToolRunner 刻意保留为顺序执行(runner.py 里 for index, supplier_call in enumerate(supplier_calls)),正是因为「并发」是一个需要说明理由的决定。
call_id:让结果与请求脱离位置关系
同一个 Runner,接受同一轮两个请求的脚本:
--- ② 同一轮两个请求 ---
status = completed answer/error = INC-001 更严重(high),两者都指向各自服务的内部瓶颈。
[tool.requested] run-9a4b0ffbe305:step-0:call-0
[tool.finished ] run-9a4b0ffbe305:step-0:call-0
[tool.requested] run-9a4b0ffbe305:step-0:call-1
[tool.finished ] run-9a4b0ffbe305:step-0:call-1
本轮 tool.requested 数量 = 2
call_id = run-9a4b0ffbe305:step-0:call-0 name = lookup_incident
call_id = run-9a4b0ffbe305:step-0:call-1 name = lookup_incident
下一轮消息序列 = ['user', 'assistant', 'tool', 'tool']
两个请求工具名完全相同,只有 call_id 不同。所以事件、日志、结果、审批、幂等都必须以 call_id 为外键——工具名不是标识一次调用的字段。
并发之下:顺序不可信,而 gather 会给你一个假象
把「完成后按 call_id 归位」这件事真的做错一次,才记得住。examples/09_offline_advanced.py 第 ④ 组故意让先发出的调用最后完成:
--- ④ 并发:完成顺序 ≠ 请求顺序 ---
请求顺序 = ['call-a', 'call-b', 'call-c']
真实完成顺序 = ['call-b', 'call-c', 'call-a'] (as_completed)
gather 返回序 = ['call-a', 'call-b', 'call-c'] (始终对齐入参)
按 call_id 归位后:
call-a -> lookup_incident ok=True
call-b -> lookup_incident ok=True
call-c -> get_service_status ok=True
有没有对不上的结果? []
这里藏着一个 Python 开发者很容易踩的坑:asyncio.gather 的返回值永远对齐入参顺序,它自己会帮你排好。也就是说,用 gather 收集结果时「顺序假设」恰好成立——于是你以为位置配对是安全的,直到某天换成 as_completed 做增量返回,或者某一个任务由后台 worker 完成再从队列取回,错误立刻暴露。
结论只有一个说法:按 call_id 归位,不要按位置归位。判断标准很简单——把结果列表打乱后你的代码是否仍然正确。
部分成功:必须先规定语义
三个查询里有一个失败,这次运行算成功吗?这个问题不能等到线上再决定。ToolRunner 的策略写在 runner.py 里,不同 code 的后果完全不同:
| 结果 code | Runner 行为 | 原因 |
|---|---|---|
incident_not_found 等业务错误 |
继续往返给模型 | 模型可以解释的事实 |
approval_required |
waiting_for_approval,停止消费后续步骤 |
副作用不能抢跑 |
unknown_tool / tool_internal_error / invalid_tool_output |
failed,整次运行失败 |
不是模型能修的问题 |
--- ③ 部分成功:一个 200 一个业务错误 ---
status = completed answer/error = INC-001 有结果;INC-999 不存在,我没有它的信息。
[tool.requested] run-2dc3755f6d8b:step-0:call-0
[tool.finished ] run-2dc3755f6d8b:step-0:call-0
[tool.requested] run-2dc3755f6d8b:step-0:call-1
[tool.failed ] run-2dc3755f6d8b:step-0:call-1 code=incident_not_found
注意 status 仍是 completed:单次工具失败不等于运行失败。但用户那一侧要看得懂「这份结论只覆盖了一部分」——所以屏幕上呈现最终答案时,是否要标记「部分完成」,是产品决策,不是协议问题。
流式:分片是传输形态,不是调用
流式响应把工具名和参数切成很多片送过来:
flowchart TD
A[chunk 到达] --> B[累积 name / arguments 缓冲]
B --> C{arguments 是否可整体解析?}
C -->|否| A
C -->|是| D[组装完整 assistant 消息]
D --> E[内部协议 + schema 校验]
E --> F[执行一次完整 ToolCall]
C -->|流被取消/出错| G[保留事件,不执行半成品]
规则只有一句:没有凑齐就不是一次调用。用 09_offline_advanced.py 的第 ⑥ 组把这句话跑出来:
--- ⑥ 流式:分片到达时不执行,收束后才执行 ---
chunk cumulative_name arguments 可执行?
1 lookup_ False
2 lookup_incident False
3 lookup_incident {"incident_ False
4 lookup_incident {"incident_id": "IN False
5 lookup_incident {"incident_id": "INC-001"} True
第 5 片才凑齐可执行形态;前面所有片都不成立。
凑齐后执行 ok = True tool = lookup_incident
前三片里工具名就已经完整了。名字完整不代表可以执行——把第 2 片当作一次请求发出去,参数还是空的,schema 会立刻报错,而这个错误会被记成「模型瞎填参数」,冤枉了模型。
流式的另一半风险在「看起来已完成」:用户侧文本已经显示出来了,工具还没执行。前端必须区分「模型正在说」和「这次调用已经发出并被确认」,否则取消按钮按下时用户不知道副作用到底有没有发生。
说明:
examples/06_streaming_tools.py走的是真实 Ollama 的流式接口,需要本地模型;这一节的确定性验证用上面这组离线实验代替。ScriptedAdapter明确不支持流式(会抛NotImplementedError),因为它根本不需要——「按脚本说话」没有分片的必要。
失败注入
改 runner.py / 09_offline_advanced.py 后重跑:
| 改法 | 预期观察 | 说明 |
|---|---|---|
工具结果按 zip(calls, results) 配对 |
打乱完成顺序后结论错位 | 位置不是主键 |
把 as_completed 结果直接追加进 messages |
说明为什么看似无害 | 多数供应商不要求 tool 消息与请求同序,但你的日志与回放会错位 |
| 收到第一个可执行分片就执行 | 第 6 组实验会停在第 2 片 | 半截调用会变成一次假请求 |
一个失败就 return failed |
第 ③ 组从 completed 变 failed |
丢掉「模型可以解释部分事实」的能力 |
生产替换点
| 教学实现 | 生产替换 |
|---|---|
顺序 for 循环 |
带并发上限的执行器 + 依赖图调度 |
| 一次性追加消息 | 增量事件流,call_id 贯穿事件、日志、审批、幂等 |
asyncio.gather |
结果按 call_id 写入结果表,前端按实体渲染而非按到达顺序 |
| 流式直追加 | 收束器 + 超时/取消保护,取消时不执行半成品 |
| 内存事件列表 | 持久化的追加式事件存储,支持断线续传 |
练习与验收
练习:把第 ④ 组实验的 delays 随机化,跑十次,统计「完成顺序等于请求顺序」的次数。然后把归位逻辑改成按 call_id,再跑十次,比较两种实现下"结果正确"的次数。
验收标准:随机延迟下,位置配对必须出现至少一次错误;按 call_id 归位的版本十次全对。如果你的实验跑不出这个差别,说明延迟分得不够开。
本章检查点
- 为什么
asyncio.gather会掩盖顺序问题?换到什么场景它会暴露? - 两个工具名相同的并发请求,除了
call_id还有什么办法区分?为什么都不如call_id? - 流式响应里工具名已经完整但参数还没齐,这时能不能执行?说出一个具体后果。
- 部分成功时
status该是什么?如果这个信息不给用户看,会发生什么?
现在能解释什么
你能说清:多个请求的关联靠 call_id 而不是位置;并发会重排完成顺序,gather 只是恰好掩盖了它;流式分片没凑齐就不能算一次调用;部分成功要先定义语义。下一章进入最后一个边界——哪些工具即使参数正确也不许自动执行。