多 Agent 团队:持久队友、信箱与原子认领
本文总结s13 在任务板上加团队运行时:常驻队友、文件信箱、锁内认领、worktree 绑任务、类型化协议。
跟读 learn-claude-code 第 5 站 · 上一站:任务图、后台与定时 · 下一站:技能、MCP 与集成 · 全程:总览
这一站只有一章,但它是整门课里最长的代码(s13_agent_teams/code.py 约 1900 行)。它的前置是 s10 任务系统,同时沿用 s03 权限和 s04 Hooks;s11 后台任务和 s12 定时调度不在本章,测试专门断言了这一点,三者到 s15 才合流。
概括起来,s13 在 s10 的任务板上加了一层由 Lead 管理的团队运行时。持久队友各跑一个 WORK/IDLE 线程,用文件信箱通信,事件由运行时推给 Lead;空闲队友在锁内原子认领 ready 的任务;任务绑定的 worktree 决定工具的工作目录;关机和计划审批走带 request_id 的类型化协议。
s13 多 Agent 团队:一个 Agent 装不下的时候
子代理在什么时候不够用
README 的例子:重构一个示例后端,清理配置加载、认证和测试,保持现有接口,确保测试通过。
单 Agent 的问题有两个。一是串行:三块工作彼此独立,却只能一块一块做。二是上下文腐烂:做到测试时,配置加载阶段读过的细节早已被挤出或被压缩掉,三份工作的工具输出在同一个上下文里互相污染。
s06 的子代理解决了上下文隔离,但它的形态还是一次函数调用:
- 一次性:每次从全新的
messages开始,返回最终文本后整个上下文丢弃。第二次派活时,上次摸清的"token 刷新逻辑在哪"要重新摸一遍。 - 同步阻塞:父 Agent 在它返回前什么都干不了,没有真正的并行。
- 单向:子代理不能中途问父 Agent,父 Agent 也不能中途追加指令或叫停,它只能交卷。
- 没有共享工作面:子代理不知道任务板,做完一个不会顺手接下一个。
所以判断标准可以这么记:边界清楚、一次性、只要结论的子问题("查一下这个函数在哪些地方被调用"),子代理够用而且便宜;多块可并行、持续时间长、需要中途转向或审批、会并行改同一个工作区的任务,才需要队友。代价是成本按人头和轮数相乘,再加上 Lead 每次被唤醒都要带完整历史调用一次模型。
整体结构
用户 ──需求 / 确认──► Lead(主线程)
工具:create_task、update_task、create_worktree、spawn_teammate、
send_message、request_shutdown、request_plan、review_plan
▲ result / idle_notification / plan_approval_request / *_response
│ │ message / shutdown_request / plan_*_response
│ ▼
.mailboxes/<name>.jsonl 一人一个文件,追加一行一条消息
│
┌────────────┼────────────┐
alice bob carol 每人一个 daemon 线程,WORK ⇄ IDLE
│ │ │ 独立 system、messages;工具表里没有
│ │ │ update_task、spawn、create_worktree
└──── claim_task / complete_task(线程锁 + .tasks/.lock 文件锁)────┐
▼
.tasks/task_xxxxxxxx.json 比 s10 多一个 worktree 字段
worktree = null → 仓库根目录
worktree = "auth" → .worktrees/auth(分支 wt/auth)
teammate_assignments[owner] = {task_id, cwd} 所有文件和 shell 工具从这里取目录
三层可以分开记:任务板是控制面(谁做什么),worktree 是执行面(在哪做),信箱是通信面(怎么说)。协议在控制面之上,专门管关机、审批这类必须可追踪的事。
持久队友:WORK 和 IDLE 两种状态
每个队友是一个 TeammateRuntime,持有自己的 system prompt、messages 和工具处理函数,跑在一个常驻线程里:
run():
state = "continue"
loop:
if state == "idle": 等活(收到合法关机请求就退出)
state = work() 一次模型调用
finally: 释放租约,未完成的任务退回 pending,清理团队状态
work():
先读自己的信箱(关机请求 → 退出) s13_agent_teams/code.py 第 1273 行
调模型
有 tool_use → 执行,追加 tool_result → continue
纯文本 → 发 result(计划待审批时不发)
→ 计划待审批:状态 waiting_approval,不发 idle
→ 否则:在轮次边界释放已完成任务的租约,发 idle_notification → idle
等活:
在信箱上等最多 2 秒,有信立刻醒
有信 → 处理;产生了新的 user 消息就回到 WORK
无信 → claim_next_task(),认领成功就追加 "[Auto-claimed task ...]" 回到 WORK
几个设计点:
- 每个
work()开头先读信箱,中途转向就靠这一步。Lead 发来的指令会在下一次模型调用前作为 user 消息出现,永远不会插在一对 tool_use 和 tool_result 中间。 result和idle_notification拆成两个事件(第 1305-1320 行)。前者回答"这项任务产出了什么",后者回答"这个人现在能不能接新活"。Lead 收到 result 去验收,收到 idle 才派下一个;等审批时只抑制 idle,Lead 不会误以为它空闲。两个事件由同一线程顺序写进同一个文件,Lead 一定先看到 result。- 空闲时先看信箱,再看任务板。关机、审批结果、Lead 的直接指令都比自己找到的活重要。
- 不自动退出。旧版队友空闲 60 秒就退出、用
sleep(5)轮询;新版用条件变量即时唤醒,只能通过关机协议结束。
换个做法会坏在哪:用子代理反复调用,每次都失忆、而且阻塞 Lead;用 asyncio.gather 并发跑多个子代理能并行,但仍然一次性、单向,中途不能追加指令、不能要计划、不能自己找活。
代价也要看清:教学版没有轮数上限,测试甚至断言超过 10 轮工具调用后队友继续运行、messages[0] 不变;也没有上下文压缩。长期运行的队友会撑爆上下文窗口,失控风险只靠"用户确认加 Lead 显式关机"兜底,没有硬预算。
文件信箱,事件由运行时推给 Lead
信箱是 .mailboxes/<name>.jsonl,一条消息一行 JSON:{from, to, content, type, ts, metadata}。发送是加锁追加一行再 notify_all;读取是读出全部后删除文件。ensure_ascii=True 保证内容里的换行和中文都被转义,一条消息不会跨行。
Lead 侧的循环(第 1854 行起)同时等两样东西:终端输入,和自己信箱里有没有新信。有信就由 consume_lead_inbox() 统一取走,先更新协议状态,再把事件格式化成一条 [Team events] user 消息,开一轮 Lead 的 agent_loop。系统提示明确要求 Lead 在 spawn 之后结束本轮,不要轮询(第 641-655 行)。
为什么这么做:
- 不共享一个 messages:README 原话的意思是,一个队友的工具结果会进入另一个队友的推理上下文。上下文隔离是多 Agent 的第一收益,共享就没了。
- 不直接调用对方的方法:直接调用会在被调方的执行流里插一脚,打断它正在进行的模型调用或工具配对,还要求双方同时在线。异步消息让收发解耦,接收方只在自己的轮次边界消费。
- 不给模型一个
check_inbox工具:旧版就是这么做的,结果模型要么忘了查,结果丢失;要么一直查,空转烧 token。新版把投递变成运行时职责,测试锁定check_inbox不在工具表里。 - 用文件而不是内存队列:能直接
cat调试,能跨进程,进程崩了还在磁盘上。 from不由模型提供:队友的send_message处理函数用闭包绑定了自己的名字,模型冒充不了别人。队友只能给 Lead 或活跃队友发信。- Lead 跑一轮期间的事件先排队,本轮结束后一次性投递。Lead 每轮看到的世界是一致的。
consume_lead_inbox必须是 Lead 信箱唯一的消费者,因为读是破坏性的,别的路径读了就会绕过协议匹配。
代价:读完即删是至多一次语义,读出后崩溃消息就丢了;信箱只有进程内锁,不像任务板有文件锁,跨进程并不安全;条件变量只能唤醒同进程的等待者,跨进程靠超时兜底。
锁内原子认领 ready 任务
空闲队友找活分两步:先 scan_unclaimed_tasks() 扫出 pending、无 owner、依赖已满足的任务,这只是一个快照;再逐个调用 claim_task(),认领整段在锁里重新检查(第 281-308 行):
with task_store_lock():
task = load_task(task_id) # 锁内重读,不信扫描快照
if task.status != "pending": ...
if task.owner: ...
if teammate_assignments.get(owner): ... # 上一轮的租约还没释放
if _owner_in_progress(owner): ... # 磁盘上还有没做完的任务
if not can_start(task_id): ... # 依赖,fail-closed
cwd, error = task_worktree_cwd(task) # 绑定的 worktree 坏了就不让认领
task.owner = owner; task.status = "in_progress"; save_task(task)
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
advance_assignment_version(owner) # 让旧的计划审批失效
task_store_lock() 是线程锁加 .tasks/.lock 上的 fcntl.flock。flock 对同一进程的不同文件描述符不可重入,所以用线程局部的深度计数,只有最外层真正加锁。保存改成写临时文件再 os.replace,不会留下半截 JSON。
为什么这么设计:
- 为什么扫描过了还要锁内复查:从"看到"到"写入"之间别人可能已经认领,这是典型的检查时刻和使用时刻不一致。多人可以同时发现同一个任务,只有一个能认领。s10 的
claim_task就缺这一步。 - 为什么不让 Lead 逐个派发:Lead 会成为瓶颈,每派一次都要一次 Lead 的模型调用。自助认领让"前置完成、后续变 ready、空闲队友接上"这条链完全不经过模型。
- 为什么线程锁不够:README 考虑了另一个 harness 进程共用同一个任务目录的情况,线程锁管不住别的进程。笔记里有一个实证:s15 在 Windows 上没有 fcntl 时退化成只有线程锁,跨进程认领测试失败,断言
2 != 1,两个进程都认领成功了。同理,环检测也必须在跨进程锁里做,否则两个进程同时加互逆依赖就会成环。 - 为什么一个 owner 同时只能有一个任务:cwd 是按 owner 存的单值,同时持有两个任务,工具调用就不知道该落到哪个目录。
worktree 绑在任务上,租约只在轮次边界释放
worktree 是可选的。Lead 用 create_worktree(name, task_id) 为某个任务建一个 git worktree,分支固定为 wt/<name>。认领时解析出目录写进租约 teammate_assignments[owner]["cwd"],此后 bash、读写、编辑、glob 全部从这里取工作目录,文件工具还用 safe_path(p, cwd) 限制在这个目录内。
- 为什么绑任务不绑队友:任务才是"一份改动"的单位。队友会换任务,任务也可能被退回 pending 由别人接手,接手者应该进同一个目录、看到已有进度,事后也能按任务审查、合并或丢弃分支。
- 为什么是可选的:创建 worktree 有磁盘、依赖安装、合并的成本,只读或互不相交的任务直接用仓库根目录。
- 为什么坏掉的绑定报错而不是回退到仓库根目录:绑定的 worktree 被删了、没在 git 注册、分支不对,认领失败、扫描跳过、已认领的工具调用报
Invalid task assignment。回退的话,以为是在隔离区改,实际改的是主仓库,这种静默错误比报错危险得多。校验以git worktree list --porcelain为准,目录存在只是弱证据,不能信。 - 为什么租约只在轮次边界释放:模型经常在同一次回复里先
complete_task,再跑一下git status或写个总结文件。如果完成时立刻清掉租约,Lead 后续的工具会悄悄回落到仓库根目录,把文件写进主仓库;队友则会报"先认领任务"。所以complete_task只改状态,release_completed_assignment()(第 457-470 行)在队友变成 idle 时、Lead 在agent_loop结束时才调用。测试锁定了"完成后的写入仍在 worktree 里"。这段窗口里该 owner 不能认领新任务,是刻意的。 - 创建交给模型,删除只留给宿主:创建可逆、风险低;删除可能丢掉未提交的工作。
remove_worktree不在任何工具表里,还要求任务已完成、没有租约指向它、git status --porcelain --ignored干净(连被忽略的文件也算数据),并且永远保留wt/<name>分支。 - worktree 不是沙箱:系统提示里反复强调它只改变默认工作目录。文件工具受限,但 bash 可以
cd ..去任何地方,真正的隔离要靠容器或权限系统。
关机和计划审批:带 request_id 的类型化协议
普通协作可以发自由文本,但关机和计划审批会改变运行时行为(线程退出、解锁写工具),不能让模型去猜一段话的意图。两种协议共用一个状态机:pending 到 approved 或 rejected,终态不可再变。
关机(Lead 发起)
Lead: pending_requests[req] = {type: shutdown, sender: lead, target: alice, status: pending}
发 shutdown_request{request_id}
alice 在下一个 work() 开头读到 → 校验通过 → 运行时直接回 shutdown_response{request_id, approve: true}
→ 线程退出,手上未完成的任务退回 pending
Lead: match_response 校验 type 对应、from 是 target、to 是 sender、status 仍是 pending → approved
计划审批(队友发起,Lead 审批)
spawn 时 require_plan=True,或 Lead 调 request_plan → 闸门 required
alice 调 submit_plan → 新 request_id,记录 work_version 和 task_id,闸门 pending
Lead 调 review_plan(req, approve) → Lead 端校验后改 status,发响应
alice 读到响应 → 再校验一遍 → 闸门 approved 或 rejected
- 每个字段防一类错:
request_id把回复关联到具体请求,并发多个请求时不串;type防止拿关机的回复去批准计划;status防重放,同一条回复第二次投递无效。代码还多一层 from/to 校验(第 959 行附近):bob 不能替 alice 确认关机。 - 计划闸门在工具分发层强制:
_run_teammate_tool()(第 1038-1057 行)在执行 bash、写文件、改文件之前检查闸门,不在"不需要"或"已批准"就直接返回Blocked。读文件、看任务、提交计划不受限,队友可以先调研再写计划。complete_task也检查闸门,堵住"不交计划直接宣布完成"的旁路。只在提示词里写"先交计划再改代码",模型可能不听。 - 审批绑定工作身份,两端各校验一次:认领和释放任务都会让
assignment_versions加一。Lead 审批时校验一次版本和任务,队友应用响应时再校验一次。只校验 Lead 端会留下窗口:审批发出到被队友读到之间,队友可能已经自动认领了新任务,旧审批不能放行新任务的写操作。 - 关机由运行时应答:旧版让队友模型用一个工具决定是否同意关机;新版由运行时校验后直接同意。控制面从模型手里收回到代码里,更可靠,代价是队友没法说"我还差一步,晚点关"。
request_id只是 6 位随机数字,不是安全令牌;安全性来自 from、to、type、status、版本的多重校验。普通send_message不改变闸门和版本,聊天不能当审批。
为什么这么设计:几条主线
把上面的细节收拢,s13 相对旧版四章(团队、协议、自治、worktree)的演进方向有三条:
- 把"让模型记得去做"的事改成"运行时保证做":信箱投递、关机应答、计划闸门都从模型工具变成运行时逻辑。
- 把本地状态文件换成权威来源:worktree 以 git 注册表为准,任务以磁盘文件加文件锁为准。
- 所有失败路径 fail-closed:依赖未知不放行,worktree 坏了不回退,后台线程不能问用户就把危险操作升级给 Lead(返回"需要 Lead 来执行")。
还有一条在团队方案层面:Lead 先提出一个小团队方案,等用户确认后才能 spawn。启动队友改变了成本、并发度和"谁能改工作区",这是用户该拍板的执行边界。
和固定的多角色流水线对比
常见的另一种"多 Agent"是固定编排的多角色流水线,比如看多、看空、裁判三个角色依次各调一次模型,角色之间传校验过的结构化 JSON,流程写死在代码里,每次输入和步骤都一样。这其实更接近第 7 站的 s16 固定编排(模型决定单步,脚本决定流程),每个角色连 s06 子代理都算不上,因为没有工具、没有循环。
把它改成 s13 式团队能换来多轮真辩论、角色可以调工具查更多资料、空闲角色自动接下一个标的。失去的是可复现性(同样的数据、不同的对话轮数可能得出不同结论,准确率就不再是在比较同一个方法)、可预测的成本(持久队友加 Lead 每次被唤醒都带完整历史)、更小的注入面,以及简单性。还有一个风险是多轮互相说服导致观点趋同,削弱对抗本来要带来的信息量。
粗略的取舍:步骤固定、需要可复现评估的流程用代码编排;路径取决于中间发现、需要长期协作和并行改工作区的开放任务,才值得上团队。
坑与局限
- 原生 Windows 跑不起来:顶部
import fcntl,Lead 等输入用的是对 stdin 的select.select(第 1846 行),两者都是 POSIX 专属。笔记里在 Windows 上跑测试,加载 s13 的用例全部因为找不到 fcntl 报错,要用 WSL、Linux 或 macOS。 - 队友的 PreToolUse 返回值被丢弃(第 1051 行),而 Lead 的
execute_tool会检查返回值。自定义的阻断型 hook 对队友不生效。 require_plan只约束第一个任务:轮次边界释放租约时会把闸门重置为"不需要",之后自动认领的任务不再要求计划,README 没有明说(按代码走读得出,待验证)。- 队友没调
complete_task就给出最终文本:任务仍是 in_progress,租约不释放,队友进入 IDLE 但永远认领不到新任务,挂着等 Lead 发现。这是 fail-closed 的结果,但 Lead 只能从 result 的内容里自己看出来(按代码走读得出,待验证)。 - daemon 线程在主进程退出时被直接杀掉,不走
finally,in_progress 任务留在磁盘上没人接手;pending_requests从不清理,队友已经死掉时关机请求会永远 pending。生产化需要任务租约心跳加过期回收、协议请求加超时。 - "先确认再 spawn"只是提示词约束,
spawn_teammate的代码里没有检查用户是否确认过。 - 教学版没有 token 预算和轮数上限,跑实验时先只开两个队友、任务写小一点。
和真实 Claude Code 的差别
- 两种多 Agent 并存:真实 Claude Code 同时有子代理(一次性,只回传最终报告,对应 s06)和团队里的队友(持久、可以互发消息,对应 s13)。可见证据是
SendMessage工具描述里"消息排队,在接收方下一个工具轮次被取出",和 s13 在每个work()开头读信箱的语义一致。 - worktree:子代理可以用 worktree 隔离模式在临时 git worktree 里运行,没有改动时自动清理;退出 worktree 时如果有未提交改动会拒绝删除,除非显式丢弃。这和 s13 删除前要求工作区干净是同一思路。
- 权限:真实产品里每个会话有自己的权限模式,
SendMessage的描述明确禁止让同伴代做被自己权限拒绝的事。这比 s13 "危险命令升级给 Lead"更严格。 - 任务板和投递:公开文档描述团队有共享任务列表、认领用文件锁防竞争、存在用户目录下(推断,具体路径以官方文档为准);事件由运行时投递、队友停下时自动通知 Lead(推断)。
- 计划审批和关机:队友先在计划模式给出方案、由 Lead 审批后执行,Lead 可请求队友关闭(推断)。
- 工程化:真实产品有自动压缩和用量限制,支持 Windows(推断其使用跨平台锁),官方也提示团队模式消耗明显更多 token。
自测
子代理和队友的本质区别是什么?给产品选型时看哪些因素?
参考:区别在生命周期和通信模型。子代理像函数调用:全新上下文、同步阻塞、只回传最终文本、结束即销毁,主要收益是上下文隔离。队友像常驻进程:独立线程、跨任务保留上下文、有身份和信箱、能双向通信、能自己认领任务,收益是并行、持续身份和协作。选型看任务能否并行且彼此独立、是否需要中途转向或审批、上下文复用的价值是否超过额外成本、会不会并行写同一工作区,以及成本预算(人数乘轮数,再加 Lead 每次被唤醒的开销)。
为什么用文件信箱加运行时投递,而不是让模型调用 check_inbox?代价是什么?
参考:给模型一个查信工具,模型要么忘了查导致结果丢失,要么一直查空转烧 token。运行时监听信箱、由唯一的消费者取走、把事件作为一条 user 消息注入,模型只处理已投递的事件。异步消息还保证接收方只在自己的轮次边界消费,不会插进一对 tool_use 和 tool_result 中间。文件可观察、可跨进程。代价是读完即删的至多一次语义,信箱只有进程内锁,跨进程不安全。
扫描已经过滤了 ready 任务,claim_task 为什么还要在锁里重查一遍?为什么需要文件锁?
参考:扫描结果只是快照,从看到到写入之间别人可能已经认领,所以检查和写入必须在同一个临界区里,锁内重读任务再复查全部条件,多人可以同时发现、只有一个能认领。线程锁只管同一进程的线程,另一个 harness 进程共用任务目录时需要 .tasks/.lock 上的 flock。s15 在 Windows 上退化成线程锁后,跨进程认领测试出现两个进程都认领成功,就是反例。
为什么任务完成后不立刻释放 cwd 租约?
参考:模型常在同一次回复里先 complete_task 再执行 git status 或写总结文件。如果完成时就清租约,Lead 后续工具会悄悄回落到仓库根目录,把文件写进主仓库,队友则会因为没有任务被拒绝。所以完成只改状态,租约在队友转 idle 或 Lead 本轮结束这样的轮次边界才释放;这段时间里该 owner 不能认领新任务。
关机和计划审批为什么要 type、request_id、status 的协议?work_version 为什么要在两端都校验?
参考:这两类消息会让线程退出或解锁写工具,不能靠模型理解自然语言。request_id 关联具体请求,type 防止跨协议误用,status 防重放,再加 from/to 校验防冒充。work_version 把审批绑定到"某队友在某任务上的某份计划",认领或释放任务都会让版本加一。Lead 审批时校验一次,队友应用响应时再校验一次,因为审批发出到被读到之间队友可能已经认领了新任务,只校验一端会留下窗口。
连接
- Agent 核心:单 Agent、子代理、多 Agent 团队的选型,是 Agent 核心问题里"什么时候需要多 Agent"的一个可落地回答。
- Agent 架构图解:从工作流到事件驱动工单:s13 的 Lead 加持久队友、共享任务板,可以放进那篇的架构图谱里,和固定流水线、主从子代理对照。
- 任务图、后台与定时:让工作撑过一轮对话:s13 直接建在 s10 任务板上,s10 留下的无锁认领和非原子保存在这里被补上。
- 计划、子代理与上下文预算:s06 子代理在那一站,对照读能看清"一次性隔离"和"持久协作"的分界。
- 固定编排与目标闸门:Workflow 运行时和 Goal Loop:s16 的固定编排是团队之外的另一条路,步骤固定、要可复现时更合适。
- 可靠执行:状态、幂等与恢复:锁内原子认领、原子写、fail-closed、至多一次和至少一次的取舍,都是可靠执行的具体手法。
- 人在回路:用户确认才 spawn、计划审批闸门、删除 worktree 只留给宿主,是人在回路在团队运行时里的位置。
- 安全边界:让权限落到工具层:worktree 不是沙箱,后台线程不能问用户,危险操作要升级而不是绕过。
课程仓库:https://github.com/shareAI-lab/learn-claude-code