第 44 章:并行工具执行
"只读工具可以并行"——只读和并发安全不是同一个属性,而事故就住在这两者的缝里。
⏱️ 快速通道(5 分钟掌握核心)
- 按并发安全性分批,不是按只读性——这是两个不同的问题
- 安全性属于这一次调用,不属于工具名字
bash是难点:分类命令字符串本身,遇到任何不认识的就 fail closed- 任何 shell 元字符——包括换行——都取消资格
- 并发结果需要
tool_use_id配对,否则 UI 会把输出挂到错误的调用上10 分钟路径:44.1-44.3 → 44.5 → Kocoro OSS Lab
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
44.1 从一个多出来的词说起
模型在一轮里发出四个 bash 调用。四个都是读。而分类器实际是这么处理的:
bash("git log --oneline -10") → 并发批次
bash("cat config.yaml") → 并发批次
bash("git stash list") → 并发批次
bash("git config --global core.editor") → 串行,大小为 1 的批次
第四个确实是读。git config --global core.editor 不带值就是查询这个设置,什么都不改。那它为什么被拎出来了?
因为分类器证明不了它是读。git config 只要后面多接一个值就变成写——git config --global user.email me@example.com 和上面那条查询之间只差一个 token——而分类器只认显式的只读形式(--get、--list 及其变体)。config 子命令下的其他一切,一律拒绝。
这是一个假阴性。它花了你一点延迟,仅此而已。现在反过来想:如果分类器对 config 家族大方一点,git config --global user.email x 就会和一个 git stash pop 一起溜进并发批次,两个 git 操作并发地改同一份共享状态。这个不对称就是全部设计。 错向「串行」,代价是几毫秒;错向「并行」,代价是你的仓库。
这底下是一个很少被拆开的问题:「只读」回答的是错的问题。只读问的是这会改变状态吗;并发安全问的是它能和它的兄弟们同时跑而互不干扰吗。这两者在两个方向上都会分叉:一个要拿排他锁的读是只读但不并发安全;两个写在完全不同资源上的操作不是只读,但一起跑毫无问题。
所以分发器应该按它真正关心的那个问题来分批。
44.2 并发安全是「这一次调用」的属性
公开 Runtime 用并发安全检查而不是只读标志来划分工具调用。没实现并发 Trait 的工具,回退到它的只读值——所以已有工具行为不变,给某一个工具加上这个 Trait,对其他任何工具都没有影响。
这个回退设计本身值得注意。一个新的分类维度应该默认落回旧行为,否则引入它就从「逐个工具的改进」变成了「全系统的风险」。
关键词是调用。file_read 对任何参数都并发安全。bash 对某些参数字符串安全,对另一些是灾难。按名字的白名单表达不了这件事——这正是为什么这个检查要接收参数,而不只是工具名。
44.3 Bash,以及 fail closed 的纪律
给任意 shell 分类是难点,公开实现直接写明了自己的姿态:
It is intentionally conservative: any shell metacharacter, any unknown leading token, or any unrecognized subcommand pattern returns false.
三道过滤,必须全过:
没有 shell 元字符。 管道、重定向、&&、命令替换——以及很重要的,换行和回车。换行是命令分隔符,所以一个含换行的「单条命令」其实是两条命令,而第二条你从来没分类过。这种缺口只在模型把命令排版成多行时才现形。
已知的起始 token。 一份严格的只读白名单。git push、npm install、curl、rm——不在名单上的一律串行,没得商量。不认识就等于不安全。
认得出的子命令形状。 对 git 这种多用途二进制,起始 token 不够。要跳过全局选项,把子命令对照安全集合检查,并且让「暗示输出或外部修改」的参数模式取消资格。
其余的一律进大小为 1 的批次。不是被拒绝——只是串行。这个区别很重要:保守的分类器从不阻塞工作,它只是拒绝把工作并行化。假阴性的代价是延迟,假阳性的代价是数据损坏。 按这个来调。
44.4 并发结果需要身份
并行一旦真的跑起来,立刻会咬人的是一个二阶问题。
串行执行时结果按调用顺序到达,UI 可以按位置配对。四个调用并发跑,它们完成顺序就乱了——git log 结束了,cat 还在跑。如果你的事件流只说「有个工具完成了」,UI 根本不知道该更新哪张卡片。
所以工具生命周期事件要携带 tool_use_id,客户端按它配对,而不是按到达顺序。Daemon 在握手时以能力令牌的形式声明这一点——这正是第 33 章那个模式:任何客户端必须能探测到的线协议变更,都要铸一个令牌,绝不靠嗅探版本号。
注意公开配置里「默认开启并发 bash」那条注释的时序:
Phase C: Desktop now consumes
tool_use_idontool_statusevents, safe to enable concurrent bash batches by default.
功能先做出来,客户端先学会按 id 配对,然后默认值才翻转。在消费方还不能归因结果时就开并行,产出的是一个「把正确数据显示在错误调用上」的 UI——那比不并行更糟,因为它是错的,而不只是慢。
44.5 快照证据
| 观察 | 4ec6772 源码位置 |
|---|---|
| 按并发安全而非只读性分批 | partition.go L41 |
| 只读回退保持既有行为 | partition.go L28 |
| 分区与批次执行 | partition.go L51 |
| 保守的 bash 分类器 | bash_concurrency.go L61 |
| git 子命令安全检查 | bash_concurrency.go L166 |
| 输出/修改类参数模式取消资格 | bash_concurrency.go L115 |
客户端消费 tool_use_id 之后才默认开启 | config.go L449 |
以上描述的是一个有日期的实现快照,不是普遍契约。
44.6 并行的代价
并发不是免费的,其中三项代价很容易被忽略。
审批顺序。 如果一个批次里两个调用都需要审批,用户会看到两个弹窗,而且顺序没有保证。审批应该在批次执行之前解决,不是在批次里面竞速。
结果体积。 四个并发工具可以同时返回四个大结果,第 36 章那个每轮聚合上限现在被撞穿的速度快了四倍。并行会加速上下文压力。
可调试性。 串行执行给你一条干净的因果轨迹。并发执行会把四个调用的日志交错在一起,而你要追的那个故障就藏在交错里。这才是「批次要小、分类要保守」的真正理由——不只是安全,还有事后能不能看懂发生了什么。
也说清楚本章没有主张什么。第 32 章描述的是更早的「准入后并行执行」模型;当前的分类粒度更细,且按调用判定。请把那一章当作它自己声明的历史快照来读。
44.7 常见的坑
拿只读当安全的代理。 这两个属性在两个方向上都会分叉。
按工具名分类。 bash 跑 cat 安全,跑 git push 不安全。名字层面的决策表达不了这件事。
忘了换行是元字符。 一条多行的「单命令」是多条命令,而你只检查了第一条。
整体放行一个多用途二进制。 git 不是一条命令。npm、docker、kubectl 也都不是。
在结果身份就绪之前上并行。 UI 把输出挂到错误的调用上,用户会对整个界面失去信任。
以为测试通过就证明安全。 并发 bug 依赖时序;测试变绿只意味着这次没发生。保守分类才是真正的防线。
Kocoro OSS Lab(10 分钟上手)
- 读
IsCommandConcurrencySafe,列出一条命令被拒绝的所有途径。数一数其中有多少是「我们不认识它」,多少是「我们知道它不安全」。 - 看
gitSubcommandSafe,想想给docker或kubectl写一个同类函数需要覆盖什么。 - 拿你自己的分发器:它是按只读分批,还是按并发安全分批?如果是只读,去找一个「两个答案不一致」的工具——每个代码库都有一个。
划重点
- 按并发安全分批,不是按只读。 它们回答不同的问题,而且在两个方向上都会分叉。
- 安全性属于这一次调用。 同一个工具,有些参数安全,有些不安全。
- 不认识的一律 fail closed。 假阴性花的是延迟,假阳性花的是数据。
- 换行是元字符。 多行命令就是多条命令。
- 先上结果身份,再上并行。 没有
tool_use_id配对,并发输出会落在错误的卡片上。
下一章:第 45 章把同一套预算纪律用到最贵的一类结果上——截图。