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 是报表变慢」——两个事件的事实对调了。事后查下来原因是:

  1. zip(calls, results) 假设结果顺序等于请求顺序;
  2. 有一个调用中途失败抛了异常,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 归位的版本十次全对。如果你的实验跑不出这个差别,说明延迟分得不够开。

本章检查点

现在能解释什么

你能说清:多个请求的关联靠 call_id 而不是位置;并发会重排完成顺序,gather 只是恰好掩盖了它;流式分片没凑齐就不能算一次调用;部分成功要先定义语义。下一章进入最后一个边界——哪些工具即使参数正确也不许自动执行。

上一章:工具失败时为什么不能急着重试? · 下一章:副作用工具怎样进入审批边界?

进入 keel 阅读