模型边吐字,Claude Code边抢跑——为什么没出乱子?

42 阅读12分钟

1 专家还在吐字,助理已经动身

上一篇《模型抽风、上下文爆、话说一半——Claude Code凭什么没崩?》结尾,我们撂下过一个没解的细节:专家还在电话里一个字一个字吐着,助理(工具)却已经动身去查了——你看到的,从来不是「模型说完,轮到工具」,而是两头重叠在一起。

这事儿越想越怪。专家的话还没说完,Harness 凭什么就知道该派哪个助理?凭什么敢让助理提前动身?更悬的是:要是专家嘴里正吐的下一个字,忽然改了主意呢?几个助理一起冲出去,撞车怎么办?

更怪的还在另一头——在你眼里,这件事特别规矩:你让 Claude Code 读五个文件,五个结果就一个接一个、按你下单的顺序整整齐齐回来,跟排好队似的。你压根感觉不到背后有什么「提前动身」,更别提「撞车」。

整齐的背后,其实没那么整齐。只是有人把不整齐的那部分,替你藏起来了。

这个「让助理提前动身、还不出乱子」的本事,藏在一个叫 StreamingToolExecutor 的东西里——不妨把它想成值班员手边的那座派工台。这一篇,我们就把这座派工台拆开。

2 不等专家说完

先问个最朴素的问题:换一个最简单的玩具循环,专家一口气要调好几个工具,它会怎么干?

它会。等专家把这一轮的话全部说完——所有 tool_use 都生成完——再一把捏起来,派助理一个一个去跑,跑完一个再跑下一个,全收齐了,统一回灌给专家。生成是一拨、执行是另一拨,两头串行,井水不犯河水。

# 玩具循环:等说完,再一个一个串行跑
for tool_use in response.tool_uses:      # 等全部生成完
    result = run_tool(tool_use)           # 串行执行,一个跑完才轮下一个
    results.append(result)

Claude Code 不等。专家每吐完一个完整的 tool_use,值班员立刻把它丢上派工台(addTool()),派工台一转头就调 processQueue(),让对应的助理动身。注意「完整的」三个字——专家吐到一半、tool_use 还没凑齐的字,派工台不碰;可一旦这一个工具的描述凑齐了,哪怕专家嘴里正接着吐下一个工具,这座派工台也照派不误。生成和执行,是重叠的流水线。

01-pipeline.png

你大概会想:这总得有个开关吧?不然谁敢让工具提前跑。有,这条路由一个叫 streamingToolExecution 的开关控制。它是 Claude Code 运行时就开着的主路径,你日常用的就是它。

可「边吐边跑」听着痛快,麻烦也立刻跟着来。专家一轮往往不止递一个工具——它可能一口气要读五个文件、跑两条命令。这些助理,能一窝蜂一起冲出去吗?谁先谁后?会不会两个助理抢着改同一份文件、改出乱子?

这是派工台要答的第一道题。

3 助理能一窝蜂上吗

第一道题:专家一轮往往一口气递好几个工具——「读这五个文件」「跑这两条命令」。派工台能让这些助理一窝蜂一起冲出去吗?

答案藏在一条规矩里,这条规矩只看一件事——这活儿改不改东西。不改的(只读),可以一起出动;会改东西的,必须一个一个来,谁也别想挤在同一拨里。

每个工具进队时,派工台都给它贴个标签,叫 isConcurrencySafe——「并发安全吗」。放行的规矩(canExecuteTool)就两句:要么台面上没人正在跑;要么自己只读、而且正在跑的那几个也都只读——这才放一起上。但凡有一个会改东西的在跑,后面排队的就得乖乖等它办完。

那「只读」怎么判定?这里有个干净到有点出乎意料的设计:isConcurrencySafe 的本质,就是**「是不是只读」**。FileReadGrepGlob 这些天生只读的工具,永远能并行;EditWrite 这些会改东西的,永远独占。最有意思的是 Bash——它得看具体命令:

// src/tools/BashTool/BashTool.tsx
isConcurrencySafe(input) {
  return this.isReadOnly?.(input) ?? false
}

lscat 是只读,能并行;rmgit push 会改状态,得独占。同一个 Bash 工具,命令不同,待遇不同。

02-concurrency.png

为什么偏偏是「只读」?道理朴素:只读不改文件、不留痕迹,几个一起跑也互不干扰,撞不了车。有没有副作用,就等于能不能一起跑——这条,Harness 替模型定了。

并发跑起来了,节奏快了。可节奏一快,新麻烦就冒头:万一正在一起跑的几个助理里,有一个办砸了呢?一起跑的兄弟,是接着跑,还是跟着遭殃?

4 一个助理炸了

五个助理正一起跑着,其中一个炸了——递回一个错误结果。剩下几个,怎么办?

接着跑?还是全停?

这要看炸的是什么工具。派工台的规矩只认一样东西:炸的是不是 Bash。是 Bash,就一道信号升空,把一起跑的兄弟紧急召回;不是 Bash(Read、WebFetch 这些),兄弟照跑,各人自扫门前雪。

源码里的注释,把理由写得明明白白:

// src/services/tools/StreamingToolExecutor.ts
// Bash commands often have implicit dependency chains
// (e.g. mkdir fails → subsequent commands pointless).
// Read/WebFetch/etc are independent — one failure shouldn't nuke the rest.
if (tool.block.name === BASH_TOOL_NAME) {
  this.hasErrored = true
  this.siblingAbortController.abort('sibling_error')
}

为什么唯独 Bash 炸了要株连兄弟?因为 Bash 命令之间常常有隐式的依赖——mkdir 没成功,后面紧跟的 cdgit commit 全成了无的放矢;一个炸了,兄弟接着跑也是白烧 token,甚至改坏状态、留下烂摊子。而 Read、WebFetch 这些,各干各的,一个失败不该连累别人。

这里得点一句,免得跟上一节搅混:株连看的是工具叫不叫 Bash,跟命不命令只读无关——lscat 这些只读的 Bash 命令,真出错了照样株连兄弟,因为它们顶着「Bash」的名号。上一节「能不能一起跑」看只读,这一节「炸了连不连累兄弟」看工具类型,两套规矩。

这儿还藏着一层比表面更深的事实。模型一口气递出五个工具时,它未必清楚哪个依赖哪个——它只是列了五件事。可 Harness 得替它把这层「隐式依赖」掂量清楚,该株连的株连、该放行的放行。这跟上一篇那个调子一模一样——模型负责张嘴递工具,Harness 负责把背后的连带关系摸清楚;可靠的,从来不是模型。

那「杀」这个动作,又是怎么做到不误伤的?靠的是三道层层嵌套的闸门。最外层,是整个回合的总闸(parent)——你按 Esc 中断,拉的就是这道,所有下层全部收。中间一层,专管 Bash 株连的「兄弟闸」(sibling)——一个 Bash 炸了,只拉这道,波及的是一起跑的兄弟,不牵连整个回合。最里层,是每个工具自己的小闸(per-tool)——权限被拒、单个工具被取消,拉这道,只杀它自己。三道闸层层套着,为的就是精准控制「杀到哪一层」:能局部解决的,绝不扩大化。

03-bash-contagion.png

兄弟被紧急召回了。可它们没真把事办完——而专家那边,正等着每个工具交回执呢。少一个回执,会怎样?

5 被召回的助理

被紧急召回的助理,半道上就停了,没真把事办完。可专家那边,立着一条硬规矩——每派一个工具,就必须收一个回执。每个 tool_use,都得配上一个对应的 tool_result,一个都不能少。

少一个会怎样?下一轮请求递上去,服务器直接甩回一个 422,拒收。协议不允许「有去无回」:你既然派了五个工具出去,就得拿五个回执回来,差一个,整封信都寄不出去。

Harness 的办法,干脆又有点哭笑不得——给每个被召回的助理,伪造一份回执,塞给专家。事是没真办,可回执得凑齐。这叫「合成 tool_result」。

而且这假回执,措辞还分情况,看是被为什么召回的(createSyntheticErrorMessage):

  • 兄弟炸了(sibling_error):回执写「Cancelled: parallel tool call {出错的那个} errored」——等于在告诉模型:「不是你的错,是隔壁那个 Bash 炸了,把你一并带走了。」
  • 你打断的(user_interrupted):回执用一句专门的 REJECT_MESSAGE——界面上显示「用户拒绝了这次操作」,而不是冷冰冰的「出错了」。(这是刻意的:你按 Esc 取消,模型该看到的是「用户不要了」,不是「我执行失败了」。)
  • 流式回退(streaming_fallback):回执写「Streaming fallback - tool execution discarded」——这是最极端的情况,整轮流式执行被丢弃重来,承接的正是上一篇讲的 fallback。

为什么非要费劲造假回执?因为 Harness 在替模型死守协议契约。模型不该、也根本不需要知道「有个助理中途被召回了」——它只管递工具、收回执。召回的狼狈,Harness 全藏在身后;喂给模型的,永远是「每个工具都有配对回执」的干净模样。

回执凑齐了,协议也守住了。可这些助理是分头出动、乱序回来的——有的先办完、有的后办完。那专家最终看到的回执,到底是什么顺序?

6 乱序回来,顺序回灌

五个助理分头出动,自然有先有后:第三个可能最先跑完,第一个还在路上。那专家最终看到的回执,是什么顺序?

答案,会让你愣一下——永远按专家下单的顺序

派工台交付结果时(getCompletedResults),严格按工具进队的先后,一个一个往外递:第一个没交差,第二个哪怕早跑完了,也得在队里压着;非得等前面的都递出去了,才轮到它。代码里这道「压着」的关卡,落在一处 break 上——遇到「还在跑、而且不是只读」的工具,直接停住,后面的不许插队。

这就有点反直觉了:既然都并发跑起来了,为什么交付的时候又退回到「严格排队」?

因为并发是 Harness 内部的事,对模型必须透明。你想想模型的处境:它一口气递了五个工具,它期待的就是按递的顺序,看到五个结果——这是它理解「我干了什么」的方式。谁先跑完、谁被中途召回、谁的回执是合成的……这些并发的鸡飞狗跳,模型一律不该知道。Harness 把它们全吞了,喂给模型的,是一个整整齐齐、按序返回的有序世界。

还记得开头的悬念吗?「在你眼里工具结果总是整整齐齐按序回来」现在答案可以揭了:那个「整齐」,是 Harness 用一整套并发、株连、合成、保序换来的。模型看到的世界永远有序——并发的混乱,值班员自己吞了。

唯有一种消息,不等排到队首就递出去:工具执行过程中的进度消息(progress)。它一产生就即时回灌,不等工具完成。这是「严格保序」里给「实时反馈」特意开的小窗——毕竟你盯着屏幕等的时候,总想看到点动静。

边吐边跑、并发二分、Bash 株连、合成回执、保序回灌——把这五样拼在一起,就是派工台的全貌。该画张总图了。

7 全景图

把前几节拆过的零件,拼成一张总图:

04-overview.png

图上有条暗线,得点出来。模型能看到的,只有最右端那个绿色的「有序世界」——它收到的,永远是按序排好的结果。而图上左半边那一整套——派工台收单、并发判定谁能一起跑、Bash 出错株连兄弟、被召回的补合成回执、最后按顺序回灌——全是它看不见的 Harness 内部加工。模型只管往里递 tool_use,有序的结果就从右端流出来;中间这座工厂怎么运转,对它是个黑盒。

8 模型看到的世界,从来整齐

回到值班室。这一回,值班员手边多了座派工台

专家还在电话里一个字一个字吐着,值班员一边听、一边把听到的完整派工单丢上派工台;派工台呢,正忙着调度——哪些助理能一起出动、哪些得老老实实排队、谁炸了要紧急召回兄弟、被召回的怎么补一份回执、最后怎么按顺序把整齐的结果递回给专家。专家什么都没察觉,它只觉得:我递了五个工具,五个结果整整齐齐回来了,真利索。

智能在模型,有序在 Harness。模型看到的世界从来整齐,不是因为它真的整齐——是 Harness 把并发的混乱,自己吞了。

这就是玩具和生产级之间的鸿沟:玩具循环一个一个串行跑;生产级背后,是一整座替模型把混乱吞掉的派工台——而这一切,模型一个字都不知道。

9 后续

三部曲走到这儿,「行动回路」这个器官补齐了:入门篇讲循环怎么让模型动起来,上一篇讲怎么不让它崩,这一篇讲怎么让它又快又稳

可这一路,我们一直把「助理」当黑盒——丢给它一个 tool_use,它就办事、交回执。可这些助理本身,是谁造的?凭什么丢给它一句话,它就知道该干什么?一个工具,到底长什么样?

下一站,我们打开助理本身。

你会发现,在 Claude Code 眼里,「工具」这个词的范围,比你以为的大得多——它远不只是 Bash、不只是读写文件。