本文基于公开的
@anthropic-ai/claude-code@2.1.88源码进行分析。如果你没读过源码、甚至不写代码也没关系------文章前半程不出现任何代码,先用一间餐厅把整件事讲清楚。
开场:先别管代码,看一间餐厅怎么出菜
想象你走进一家很特别的餐厅,点了一道菜。这家餐厅的运作方式,跟你理解的 AI 编程助手,是同一回事。
它特别在哪?这家店的大厨,是个失忆症患者。
这位大厨厨艺天下第一,任何菜他都会做、任何难题他都能判断。但他有个毛病:每做完一件事,挂断电话后,就把刚才的事忘得干干净净。 他甚至不在店里------他在很远的地方,你只能打电话找他。
于是这家店雇了一个接线员 ,坐在店里。接线员不会做菜,一道菜都不会,他唯一的工作就是打电话问大厨,然后照做。店里还有一块大白板,从你进门那一刻起,你说的每句话、每次对话的结果,都被记在上面。
现在,你下单了。看这道菜怎么做出来的:
-
接线员先看一眼白板 ,然后拿起电话打给远方的大厨。因为大厨失忆,接线员必须把白板上的内容从头到尾念一遍给他听,不然大厨根本不知道现在是什么情况。念完,问一句:"接下来该干嘛?"
-
大厨在电话那头动脑子,给出两种回答之一:
- 要么说:"菜好了,可以上了。"
- 要么说:"你先去冰箱第三层拿块牛排、再开火煎一下。"
-
如果大厨要求动手 ,接线员就照办------他自己不做菜,但他会去按那些"料理机"的按钮(拿食材、开火、切菜)。做完,把结果写到白板上。
-
然后接线员再打一次电话,又把白板上的内容从头念一遍,再问:"接下来呢?"......
-
这个"打电话问 → 照做 → 记白板 → 再打电话问"的循环一直转着,直到某一次大厨说"菜好了"。接线员这才把菜端给你。
就这么简单。一道复杂的菜,就是靠接线员反复打电话,一步步做出来的。
这里藏着两个反直觉、但一旦想通就豁然开朗的点:
- 聪明的不是店里的人,是电话那头的大厨。 接线员从头到尾没出一个主意,他只是个纪律严明的跑腿的。所有"该怎么做"的判断,全在电话那头。
- 所谓"AI 记得我们之前聊过什么",是个错觉。 大厨啥都不记得。是接线员每次打电话,都把整块白板重新念一遍------是复述,不是记忆。
把这两点记住,你已经理解了 Claude Code 的灵魂。剩下的,全是围绕"这位失忆天才"搭起来的脚手架。
那如果一道菜太复杂,一个大厨忙不过来呢?
真实的活往往没这么简单。比如你点的这道菜,需要先翻遍整个大冷库,把所有能用的食材都找出来------冷库里塞了成百上千样东西,得一格一格翻。
如果接线员亲自去翻、还边翻边往白板上记,会发生什么?白板会被写满。 翻一样记一条、再翻一样再记一条......几百条全堆在白板上。而每打一次电话都要把整块白板念一遍,白板越长,电话打得越慢,最后长到大厨那边根本"听不完"(比如每次打电话只能说1000个字)。
这家餐厅的解法很妙:再开一间隔壁厨房,雇个临时工去干这种脏活。
临时工也有一整套班子------他有自己的接线员、自己的白板、自己电话那头的大厨。他关起门在自己那间厨房里把大冷库翻个底朝天,把自己的白板写得再乱也没关系。干完,他只回主厨一句话:"能用的就这 3 样,在这儿。" 那几百条翻找记录,全留在他自己的白板上,主厨这边的白板干干净净,只多了一句结论。
这就是 subagent(子代理)------它的头号价值不是"分担工作量",而是"分担白板污染"。
而这种"另起一套班子"的合作,按照关系的不同,一共有四种玩法。这四种,是本文后半程的重点,我先用一句话让你有个印象:
- 外包:喊个临时工,干完就地遣散,只回一句结论。(用完即焚)
- 分身:不喊外人,而是把主厨连记忆一起复制一个出去干。(带记忆的克隆)
- 协调者-工人:主厨脱下围裙当"包工头",自己不炒菜,只指挥一队工人。
- 队友:一群平等的大厨,能互相打电话商量着干。(唯一能横向对话的)
到这里,你已经在脑子里有了一整幅画面:一个失忆大厨 + 一个跑腿接线员 + 一块白板;活太脏就外包给隔壁厨房。 接下来,我们把这幅画面一层层落到真实的代码上------从这里开始会出现文件名和行号,但每一处,你都能对应回刚才那间餐厅。
一、把餐厅对回代码:角色表
先认人。上面那间餐厅里的每个角色,在源码里都有对应:
| 代码里的东西 | 餐厅角色 | 职责 | 会思考吗 |
|---|---|---|---|
| 模型(远程 LLM) | 失忆的大厨 | 所有"下一步怎么做"的判断 | ✅ 唯一的智慧来源 |
query.ts 的主循环 |
接线员 | 传话、按工具、记账、决定再不再转一圈 | ❌ 只跑腿 |
messages 数组 |
白板 | 记录整段对话------大厨唯一的记忆 | --- |
services/api/claude.ts |
唯一那条对外专线 | 把白板打包发给大厨、解析回话 | --- |
tools/ 工具 |
后厨的手脚/料理机 | 真正读文件、跑命令、改代码 | ❌ 按一下自动出活 |
| subagent | 临时工 / 分身 | 另起一套完整班子去干脏活 | (是另一个大厨) |
记住三个关键角色:失忆大厨(模型)、接线员(query.ts)、白板(messages)。整篇文章都绕着它们转。
二、一道菜的旅程:Agent 主循环(落到代码)
开场那个"打电话转圈"的故事,落到代码里,核心就是 query.ts:307 那个 while(true) 循环。我把一整圈拆成几个动作。
动作 A:打电话前,先看白板塞不塞得下
js
// query.ts,主循环开头
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
// ... microcompact / autocompact 在这之后
打电话给大厨之前,接线员先看白板长不长。因为打电话 = 把整块白板从头念一遍给大厨听(大厨失忆,只能这样),白板太长有两个后果:念不完(超 token 上限被拒接)、念一遍贵得离谱。
所以太长了先压缩:
- microcompact:小清理,只把最占地方的几条旧工具结果删掉/缩短。
- autocompact:大打包,把一大段历史概括成摘要。
这里藏着理解 AI agent 的第二把钥匙:所谓"AI 有上下文记忆",本质是每次都把整块白板重新念一遍给一个失忆的大厨听。 不是大厨记得,是接线员每次都复述。
动作 B:打电话(query.ts 里的 deps.callModel)
接线员拿起唯一那条对外专线,把整块白板念给大厨。大厨的回话是一个字一个字流式传回来的。
有个聪明细节:大厨话没说完,只要冒出一句"去查冰箱",接线员当场就派人去查 ,不等他挂电话(StreamingToolExecutor)。省时间。
动作 C:接线员这辈子唯一做的判断
大厨说完,接线员只问自己一个机械问题 (query.ts:1062 附近):
js
if (!needsFollowUp) {
// 大厨没要工具 → 这单可能做完了
}
needsFollowUp 是什么?就是"大厨的回话里,有没有冒出工具调用请求(tool_use)"。有就是有,没有就是没有,读个标志位而已。 这不是判断力------它只是检查大厨要不要动手。这恰好印证了开篇那句:接线员不产生一点智慧。
动作 D:分两条路
没要工具 → 这单做完了,走打烊流程(stop hook、token 预算检查),然后 return { reason: 'completed' }。圈结束。
要了工具 → 真正干活:
js
// query.ts 尾部:把这轮的话 + 工具结果,全部续到白板末尾,再回到动作 A
const next = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
// ...
}
这一句"续到末尾再回头",就是【转圈】的字面本体。 白板每转一圈变长一截,所以动作 A 才要压缩------不压,白板迟早撑爆。
一张图钉死这个圈:
css
┌─────────────────────────────────────┐
│ A. 白板太长?先压缩 │
└──────────────────┬──────────────────┘
↓
┌─────────────────────────────────────┐
│ B. 把白板念给大厨(流式回话) │
└──────────────────┬──────────────────┘
↓
┌───────────────────────┐
│ C. 大厨要工具吗? │ ← 接线员唯一的"判断"
└─────┬───────────┬─────┘
没要 │ │ 要了
↓ ↓
┌────────────┐ ┌──────────────────────┐
│ 打烊 → 收工 │ │ D. 派工具干活 │
└────────────┘ │ 结果续到白板末尾 ───┼──┐
└──────────────────────┘ │
回到 A,再转一圈 ←────────┘
为什么这个文件要 1700 行?
逻辑就上面这么点,凭什么写 1700 行?我读下来的结论:真正属于"转圈主干"的不到三成,剩下七成全是"临场救火纪律"。
代码里有个 state 结构体,在 7 个不同的 continue 点 被改写------意思是"接线员决定再转一圈"有七种理由,正常的"用了工具所以继续"只是其一,其余六种全是救火:
- 大厨电话占线 → 换备用大厨重打(fallback)
- 大厨话说一半被掐断(输出超长)→ 让他接着说
- 白板念到一半发现还是太长 → 当场再压缩重打(reactive compact)
- 用户按了 Ctrl+C → 把半截活干净收尾
- 转太多圈 → 强制收工(maxTurns)
所以那句话在代码层面完全成立:接线员的全部本事,是在大厨那通电话的两头,把圈转得又稳又不丢信息。智慧全在电话那头,纪律全在这 1700 行里。
三、Subagent:不是"叫个帮手",是"另起一套班子"
理解了单个循环,就能理解 subagent 了。很多人以为 subagent 是主 agent 手边一个跑腿小弟。大错。
关键在 runAgent.ts:748:
js
// runAgent.ts ------ subagent 启动时
for await (const message of query({ messages: initialMessages, ... })) {
// ...
}
看到没?它又调了一次 query() ------就是上面那个 while(true) 转圈机。
所以派一个 subagent,等于在隔壁房间凭空支起一套一模一样的电话亭:里面坐着另一个失忆大厨,配自己的白板、自己的专线、自己的工具、自己的身份 ID。
| 主班子 | 一个 subagent |
|---|---|
| 一个接线员 | 自己的接线员(独立 query 循环) |
| 一面白板 | 自己的白板 (独立 messages) |
| 一条专线 | 自己的专线 |
| 一个大厨 | 另一个失忆大厨(对主对话一无所知) |
为什么要这么重地另起一套? 目的只有一个,也是理解全部四种模式的总纲:
保护主白板的干净。 白板是大厨唯一的记忆,越长越贵、越长越容易爆。一个"读 50 个文件找东西"的活会把白板撑爆------那就外包出去,让临时工在他自己的白板上撑爆,主厨只要那一句结论(
prompt.ts:257:"return a single message back to you")。
subagent 的头号价值不是"分担工作量",是"分担白板污染"。 记住这句,四种模式全是它的变体。
四、Subagent 的四种关系模式
同样是"另起班子",按关系拓扑 分四种。区别只在三个旋钮:喂什么系统提示、给不给继承上下文、开放哪些工具权限 。底层都是同一套 query(),能力同构。
模式一:外包(主仆)------用完即焚的临时工
最常见。主厨派一个 subagent,它在自己白板上干完,回一句结论就消失。
它的一辈子(runAgent.ts):发工牌(:347)→ 搭全新空白板(:373)→ 配一套重新筛过的工具(:500)→ 写专属岗位说明书(:508)→ 开自己的转圈机(:748)→ 回一句话 → finally 里彻底拆台(:816,关服务、撤钩子、杀掉它后台开的进程、删待办)。
三个亮点:
- 省 token 省到砍记忆 (
runAgent.ts:386):Explore 这种只读临时工,出生时主动删掉 CLAUDE.md 和最大 40KB 的 git 状态。据该处源码注释,Explore 一周被派约 3400 万次,砍这一刀每周省下约 50~150 亿 token(数字引自注释原文,未独立复核)。 - 权限不泄漏 (
runAgent.ts:469):给临时工配权限时清单清空重设,主厨之前点过的"同意"绝不偷传给它。 - "别偷看"是铁律 (
prompt.ts:91):后台临时工干活时不许中途偷看它的草稿------一读,那些过程垃圾就又被搬回主厨白板,外包图的"干净"当场白费。
什么时候用:大海捞针式搜索、开放式调研、要一个"没被你带偏的第二意见"、一批能并行的独立活、又长又脏只要结论的活。
什么时候别用 (prompt.ts:235):读某个具体文件、找某个具体函数、2-3 个文件的小范围------自己干,别为芝麻小事搭厨房。
模式二:分身(fork)------带着记忆出门的克隆
特殊的主仆:省略工种时,subagent 不是新雇的陌生人,而是主厨的一个克隆 ------继承整面白板、共享缓存(forkSubagent.ts:60 的 FORK_AGENT:model: 'inherit'、tools: ['*'] + useExactTools)。
分身 vs 临时工,核心不是"贵不贵",是"这次要不要那面白板":
- 要客观清醒、怕被带偏(求证、找茬) → 临时工(白纸)
- 要懂内情、省得交代(接着你的思路往下干) → 分身(带记忆)
为什么分身便宜 :靠"让一群分身的白板前缀逐字节相同"来共享提示缓存(forkSubagent.ts:99:"all fork children must produce byte-identical API request prefixes")。构造时连"工具占位结果"都用完全相同的占位词(:93),只有末尾的专属指令各不相同(:163)。
省钱带来的副作用要打补丁 :分身原样抄了主厨说明书,而说明书里写着"优先 fork"------分身照读就会无限套娃。于是两道闸:软闸(:171 守则开头吼"STOP,你是分身,别派下属")+ 硬闸(:78 isInForkChild 扫白板里的分身标记,有就拒绝再 fork)。
一句话:省钱是分身的手段和副产品,不是初衷。 分身和外包的初衷相同(护白板),只是适配"要不要前情"这两种相反需求。
模式三:协调者-工人(coordinator/worker)------退居二线的包工头
开一个开关(coordinatorMode.ts:36 的环境变量),主厨的系统提示被整个换掉(:116):
"You are a coordinator. 你的工作是指挥一队工人去调研、实现、验证,然后综合结果。"
协调者不是新程序,就是被换了岗位说明书的主 agent。 它像个包工头------自己不砌墙,只拆活、写精确施工单、派工人、验收综合。
跟主仆的三个关键区别:
- 可持续对话 :能用
SendMessage给同一个 worker 反复追加指令(靠 worker 身上挂的pendingMessages待办队列,LocalAgentTask.tsx:162),这才是真正"继续之前的活"。 - 异步通知 :worker 干完,结果被拼成
<task-notification>(LocalAgentTask.tsx:252)、**伪装成一条"用户消息"**贴回协调者白板(coordinatorMode.ts:144)。
这个"伪装成用户消息"是全系统最巧的设计之一:
协调者的大厨只认识"用户说的话"。于是系统把 worker 的汇报包装成用户角色塞进它白板。对大厨来说,"下属汇报"和"用户发话"长得一样------它不需要理解"下属"这个概念,只需要会读用户消息。复杂的多 agent 协作,被压扁成了大厨本就会的"读对话"。
- 并行是超能力 (
coordinatorMode.ts:213):能同时派的绝不串行。
给 worker 配工具:一份全量,过四层筛子 (agentToolUtils.ts:122 + constants/tools.ts):
全量工具池
→ ① 全体 subagent 禁用(Agent 防套娃 / TaskOutput / PlanMode...)
→ ② 自定义 agent 额外禁
→ ③ 异步 worker 只放行白名单(Read/Grep/Bash/Edit... 十几样) ← 白名单制!
→ ④ 工种自己的黑名单
= worker 实际拿到的工具
注意③是白名单 (默认不给、只给明确安全的),和①②的黑名单逻辑相反------worker 的能力被有意收得比主线窄。 而协调者自己的工具是另一份"只管人不干活"的名单(constants/tools.ts:107:Agent/TaskStop/SendMessage/SyntheticOutput,一个 Bash/Edit 都没有)。
模式四:队友(teammate/swarm)------唯一的横向关系
前三种里,agent 之间要么不说话、要么只跟上级单线联系。队友是唯一能横向对话的------队员 A 能直接给队员 B 发消息,像真人团队。
它是一群长期存活、地位平等的完整 Claude Code 实例 ,各开各的进程(spawnMultiAgent.ts 里 tmux 分屏 / 独立窗口 / 同进程三种后端),靠信箱文件通信。
为什么必须"投文件" :队友是独立进程,内存不共享,A 想跟 B 说话没有共享内存可用,只能靠文件系统当公共黑板(teammateMailbox.ts:4):每个队友一个 .claude/teams/{队名}/inboxes/{名字}.json,别人往里加锁写(:134,:165 文件锁防并发),收件人自己轮询读。
系统提示专门追加一段告诉队友(teammatePromptAddendum.ts:15):"光在文本里写回复,队友是看不见的------你必须用 SendMessage 工具。" 因为白板私有,别人读不到,要让队友听见必须显式投信。
但要泼一盆冷水:队友在代码项目里大多是过度设计。
它的设计灵感来自人类团队,可代码项目的协作范式根本不同:
| 人类团队 | 代码项目的 agent | |
|---|---|---|
| 信息在哪 | 各人脑子里,必须开口问 | 落在文件里,谁都能读 |
| 怎么同步 | 靠对话 | 靠读同一份代码 |
人类需要横向对话,是因为知识锁在脑子里;agent 的成果都落在文件系统上、是公开的。 所以"A 问 B 你干了啥"大多能被"A 直接读 B 改的文件"替代------队友的核心卖点被共享文件架空了。它真正不可替代的只剩一个夹缝:协作依赖运行时状态(没法落成文件)、且参与方须长期共存时(比如一个 agent 挂着服务、另一个实时打它)。
这也解释了它至今仍是 EXPERIMENTAL + 带远程 killswitch------适用面窄、成本高(另起进程 + 文件信箱 + 海量维护代码),是四种关系里最拟人、也最少真正用得上的一种。
四种模式速查
| 模式 | 谁创造谁 | 信息方向 | 子方寿命 | 能否横向对话 |
|---|---|---|---|---|
| 外包(主仆) | 主派子 | 下派↓ + 一次回↑ | 用完即焚 | 否 |
| 分身(fork) | 我裂我 | 下派↓(带全部记忆)+ 一次回↑ | 用完即焚 | 否 |
| 协调者-工人 | 协调者派工人 | 下派↓ + 反复差遣 + 异步通知↑ | 可反复唤起 | 否(worker 互不可见) |
| 队友(swarm) | 队友拉队友 | 任意点对点 ↔ + 广播 | 长期独立存活 | 是 |
五、一条贯穿始终的铁律:白板永不共享
读到这你会发现,四种模式再花哨,都逃不出同一条底层规矩:
每个 agent 都有自己独立的白板(
messages),任何两个 agent 都不共享同一面白板。
- 外包/新雇:白板空白开局。
- 分身:创建时克隆 主厨白板的快照------注意是拷贝,不是接线(
runAgent.ts:373用[...contextMessages, ...promptMessages]新建数组)。克隆完各自生长,主厨后续改动分身看不到 (buildWorktreeNotice甚至提醒分身"文件可能已被改过,动手前重读")。 - 协调者-worker:worker 结果靠"伪装成用户消息"塞回,不是共享白板。
- 队友:靠"投信箱文件"通信,不是共享白板。
为什么这条不能破? 因为共享白板 = 两个大厨抢同一个脑子。fork 之后主厨和分身各走各的路,白板各自生长,根本不存在一个"最新版"可以同步。所以唯一干净的做法就是:分家那刻拍张快照,之后互不干涉;要沟通就走外部中介(伪装消息 / 信箱文件)。
跨 agent 沟通必须显式(创建时塞入,或事后投信),正是白板隔绝的必然结果------这不是设计选择,是"大厨各有各的记忆"推导出来的硬约束。
六、最后一把钥匙:谁在思考,谁在干活
全文的收尾,也是最容易被搞混、最值得刻进脑子的一点:Claude Code 是 CLI,不是模型。
- Claude Code = CLI :就是这些 TypeScript 代码,会转圈、会派 subagent、会拼提示词。它本身不思考。
- 模型 = 会思考的大脑 :在远程服务器上,通过
services/api/claude.ts的网络专线被调用。
以"协调者模式"为例,它准确拆成三层:
| 层 | 是什么 | 扮演 |
|---|---|---|
| Claude Code(CLI) | 这些代码 | 搭台+执行:决定喂哪份提示词、真正 spawn worker、把结果贴回白板 |
| 模型 | 远程大脑 | 做判断:读了"你是协调者"提示后,倾向于"派 worker 去干" |
| 系统提示 | coordinatorMode.ts:116 的文字 |
剧本 |
也就是说:是 CLI 让远程模型"扮演"协调者 (喂它一段提示词),但模型只负责"想说什么/想派谁",真正 spawn worker、加锁写信箱、把结果伪装送达的全是 CLI。
想的是模型,做的是 CLI。 连"让模型当协调者"这件事,都不是靠写死代码实现的,而是靠一段提示词说服那个大厨。
结语
把整篇文章压成几句:
- 智慧是租来的,工程是自己的 。最核心的
query.ts不聪明,它是纪律;聪明全在电话那头的模型。 - 所谓"上下文",是每次把整块白板念给一个失忆大厨听。白板越长越贵越易爆,于是有了压缩、有了 subagent 外包。
- subagent 是"另起一套完整班子",四种模式(外包/分身/协调者-worker/队友)是同一目的(护白板)的四种拓扑变体,区别只在"喂什么提示、给不给记忆、开放什么权限"。
- 一条铁律贯穿全部:白板永不共享。所以跨 agent 沟通必须显式。
- CLI 负责做,模型负责想,两者是两个东西,任何时候别混。
读源码最大的收获,往往不是记住某个函数,而是看清一个反直觉的设计取向。Claude Code 给我的那一下,就是:做一个强大的 AI agent,难点从来不在"让代码变聪明",而在"如何组织一个不思考的脚手架,让那个聪明但健忘的大脑,稳定地把复杂的活一步步干完"。