# 让工具自己声明并发安全:我把调度逻辑砍到一行
让工具自己声明并发安全:我把调度逻辑砍到一行
这是 《写完一个 AI 编程助手之后,我才确定 prompt 工程不是重点》 的第四篇。前几篇讲了进程模型和权限系统,这一篇讲并发调度。
代码:[https://github.com/sishenaichipingguo/code-agent)。
AI 经常一口气甩三个工具:
[ { name: 'read', input: { path: 'a.ts' } }, { name: 'read', input: { path: 'b.ts' } }, { name: 'grep', input: { pattern: 'TODO' } } ]也经常这样:
[ { name: 'read', input: { path: 'a.ts' } }, { name: 'write', input: { path: 'a.ts', content: '...' } }, { name: 'edit', input: { path: 'a.ts', ... } } ]第一组并行跑没问题。第二组并行跑就炸——同一个文件被三个操作竞争。
调度器要不要并行?怎么决定?
我试过三种方案,前两种都错了。
一、第一种错法:全部串行
最朴素的方案:永远不并行。
for(consttooloftools){results.push(awaitrunTool(tool))}正确,但慢得令人发指。读三个文件本来 50ms 能搞定,串行变成 150ms。一次轮次里有四五次 batch,累积下来用户能感觉到。
而且这是个无意义的慢——Read 工具就是无害的,串它干嘛?
二、第二种错法:调度器去猜
第二个直觉是写一张表:
constSAFE_TOOLS=['read','grep','glob','ls']constallSafe=tools.every(t=>SAFE_TOOLS.includes(t.name))跑了几天就发现 bug。比如:
bash git status ← 只读,应该并行 bash rm -rf foo ← 破坏性,绝对不能并行是同一个工具bash,但语义完全不同。把bash加进 SAFE 列表是错的,不加进去又把所有bash git status / git log / ls这种纯查询全串行了。
更糟的是,每加一个新工具,就要回到调度器更新这张表。写新工具的人必须改无关的代码——每次都会忘。
调度器不应该知道工具的语义。任何让调度器去"判断"工具的方案,都会在加新工具时退化。
三、第三种做法:让工具自己说
核心改动:在每个工具上加一个方法。
// src/core/permissions/types.tsexportinterfacePermissionCapable{isConcurrencySafe(input:unknown):boolean// ...}然后调度器只问一句话:
// src/core/agent/loop.tsconstallConcurrencySafe=tools.every(t=>{consttool=this.context.tools.get(t.name)returntool?.isConcurrencySafe(t.input)??false})if(allConcurrencySafe){returnPromise.all(tools.map(runTool))}constresults:any[]=[]for(consttooloftools){results.push(awaitrunTool(tool))}returnresults调度器一行判断。
工具自己回答:
// read.tsisConcurrencySafe:()=>true,// write.ts、edit.ts、rm.tsisConcurrencySafe:()=>false,bash是真正有意思的那个:
// src/core/tools/bash.tsisConcurrencySafe:(input)=>{constcmd=(inputasany).commandreturntypeofcmd==='string'&&classifyCommand(cmd)==='readonly'}注意签名——isConcurrencySafe(input)接收输入。同一个bash工具,对git status返回true,对rm -rf返回false。判断粒度不是工具,是工具调用。
这是这个设计真正起作用的地方。如果签名是isConcurrencySafe()(无参数),bash 就只能选一个保守的false,损失全部并发收益。
四、默认值要保守,不要乐观
有一个细节决定这套设计能不能在团队里活下来:默认值。
createTool的默认实现:
// src/core/tools/registry.tsisConcurrencySafe:spec.isConcurrencySafe??(()=>false)默认false。新写的工具如果忘了声明,自动按串行处理。忘记声明的代价是慢,不是炸。
反过来,如果默认true,每加一个新工具都可能引入并发 bug,而且测试很难发现——因为冲突只在特定时序下出现。
MCP 工具也走这个默认(src/core/mcp/client/tool-wrapper.ts):
isConcurrencySafe:()=>false这是对的,因为 MCP server 的语义对我们完全不透明,假设它危险是唯一安全的选择。
任何"必须由作者主动声明才安全"的属性,默认值都要选不安全的那一边。
五、为什么不做"部分并行"
最后一个反直觉的决定:不要做部分并行。
设想这个 batch:
[ read a.ts, read b.ts, write c.ts, read d.ts ]聪明的调度器会说:“前两个并行,等第三个串行执行,再起一个并行跑第四个。”
这套逻辑要写一个拓扑排序,要追踪资源依赖(哪些路径在被写?bash 的副作用怎么算?),还要考虑回退。代码量从 5 行膨胀到 200 行,且每个新工具都要重新审视。
我选择了最钝的方案:
全部安全 → Promise.all 否则 → 全部串行代价是上面那个 batch 退化成全串行,慢一点。但代码简单到不会出 bug,新工具加进来零成本。
能用 5 行代码解决 80% 的问题时,不要写 200 行代码解决 100% 的问题。
实际跑下来,AI 给的 batch 里 95% 要么全是 read 类(全并行),要么含 write/edit(全串行)。混合 batch 罕见,性价比不值得为它写复杂逻辑。
所以呢
这是「工程问题决定 Agent 好坏」系列的第三个例子,跟前两篇讲的是同一个原则:
- 进程模型:把阻塞操作丢给 Worker,主循环只负责调度
- 权限系统:把危险性判断丢给工具,引擎只负责仲裁
- 工具调度(这篇):把并发安全丢给工具,调度器只负责选
Promise.all或串行
框架的本职工作只有一件:定义一个让组件自我描述的接口,然后做最钝的调度。
写得越多 AI Agent 我越确信这件事。prompt 工程、chain 抽象、memory 设计这些被各种框架包装的概念,本质上都是组件自我描述 + 钝调度的问题。一旦你把它从"框架的智能"改成"组件的诚实",复杂度立刻塌一个数量级。
代码:github.com/your-handle/code-agent。
下一篇讲 Agent 长对话的核心问题:上下文窗口快满了怎么办——三种压缩策略和一个自动兜底机制。
