首页
/ learn-claude-code s09 实战:用持久队友与文件邮箱构建 Agent Teams

learn-claude-code s09 实战:用持久队友与文件邮箱构建 Agent Teams

2026-09-06 18:30:59作者:柯茵沙

在 learn-claude-code(一个从 0 到 1 构建 nano 版 Claude Code 风格 agent harness 的课程仓库)中,s09 章节回答了一个多智能体协作的核心问题:当任务大到单个 Agent 扛不住时,如何生成有身份、有生命周期、能互相通信的“队友”。本篇完整拆解 s09 的 TeammateManager 与 MessageBus 两套核心机制——基于 append-only JSONL 文件信箱、线程承载的独立 agent loop、以及 Lead 侧 9 个工具的调度设计,并对照课程源码给出可直接运行的实操步骤,帮助你掌握“文件即通信总线”这一轻量协作模式。

Agent Teams 总体架构

1. 为什么需要持久队友:s04 与 s08 留下的空白

s09 的出发点是两个已有机制都不够用:

  • Subagent(s04/s06)是一次性的:spawn → 执行 → 返回摘要 → 销毁。没有身份,两次调用之间不保留任何记忆;
  • 后台任务(s08)只会跑 shell 命令:它能执行长时间命令,但无法做 LLM 主导的决策。

真正的团队协作需要三样东西,s09 逐一补齐:

  1. 跨提示词存活的持久 Agent(outlive a single prompt);
  2. 身份与生命周期管理(名字、角色、状态);
  3. Agent 之间的通信通道

文档给出的一句话定位很准确:s09 是 harness 层的 Team mailboxes —— 多个模型,通过文件进行协调

2. 总体架构:.team/ 目录即团队状态

s09 的全部团队状态都落盘在 .team/ 目录下,目录结构如下(原文档示意,路径相对工作区根):

.team/
  config.json           <- 团队名册 + 各成员状态
  inbox/
    alice.jsonl         <- append-only,读后即清空(drain-on-read)
    bob.jsonl
    lead.jsonl

通信模型是“点对点投递到收件箱文件”:

            +--------+    send("alice","bob","...")    +--------+
            | alice  | -----------------------------> |  bob   |
            | loop   |    bob.jsonl << {json_line}    |  loop  |
            +--------+                                +--------+
               ^                                         |
               |        BUS.read_inbox("alice")          |
               +---- alice.jsonl -> read + drain ---------+

生命周期则是一条状态链:

spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN

从源码看(agents/s09_agent_teams.py),模块级常量固定了这套布局:TEAM_DIR = WORKDIR / ".team"INBOX_DIR = TEAM_DIR / "inbox",且 WORKDIR = Path.cwd()——也就是说团队目录永远建在启动脚本时的工作目录下。这一点决定了 s09 是“每工作区一个团队”的轻量模型,适合单进程内的演示与试验。

3. TeammateManager:名册持久化与生命周期管理

TeammateManager 负责维护 config.json 名册。初始化时会创建目录、加载(或初始化)配置:

class TeammateManager:
    def __init__(self, team_dir: Path):
        self.dir = team_dir
        self.dir.mkdir(exist_ok=True)
        self.config_path = self.dir / "config.json"
        self.config = self._load_config()
        self.threads = {}

源码中的完整行为补充了两点细节:

  • _load_config()config.json 不存在时返回默认值 {"team_name": "default", "members": []},存在时直接反序列化——名册天然可跨进程重启恢复;
  • 每次名册变动(spawn、状态回写)都通过 _save_config()indent=2 写回磁盘。

一个成员在配置中的形状是:

{"name": "alice", "role": "coder", "status": "idle"}

s09 用到的 status 取值有 workingidleshutdown(文档与 s10 文档中的 SHUTDOWN 由自然结束或线程退出体现)。list_all() 会把名册渲染为 Team: default + 每行 name (role): status 的文本,供 /team 命令直接打印。

3.1 spawn():注册名册 + 启动线程

spawn() 做两件事:把成员写入 config.json,然后为该队友开一个 daemon 线程运行它自己的 agent loop:

def spawn(self, name: str, role: str, prompt: str) -> str:
    member = {"name": name, "role": role, "status": "working"}
    self.config["members"].append(member)
    self._save_config()
    thread = threading.Thread(
        target=self._teammate_loop,
        args=(name, role, prompt), daemon=True)
    thread.start()
    return f"Spawned teammate '{name}' (role: {role})"

文档版本为了教学做了简化;实际源码(agents/s09_agent_teams.py)在 append 之前多了一段同名复活逻辑:

member = self._find_member(name)
if member:
    if member["status"] not in ("idle", "shutdown"):
        return f"Error: '{name}' is currently {member['status']}"
    member["status"] = "working"
    member["role"] = role
else:
    member = {"name": name, "role": role, "status": "working"}
    self.config["members"].append(member)

这带来两个可验证的行为事实:

  1. 对一个 working 中的队友重名 spawn 会直接报错而不是开第二个线程;
  2. idle/shutdown 队友重名 spawn 会把它原地复活working,并挂上一个全新线程、全新的 messages 历史(注意:名册只保留状态,旧对话历史不会恢复);
  3. 线程统一登记在 self.threads[name],标记 daemon=True,主进程退出时不会阻塞。

4. MessageBus:append-only JSONL 信箱

s09 通信层的全部实现就是一个几十行的 MessageBus 类。核心不变式:发送永远只追加,读取永远读全量并清空

class MessageBus:
    def send(self, sender, to, content, msg_type="message", extra=None):
        msg = {"type": msg_type, "from": sender,
               "content": content, "timestamp": time.time()}
        if extra:
            msg.update(extra)
        with open(self.dir / f"{to}.jsonl", "a") as f:
            f.write(json.dumps(msg) + "\n")

    def read_inbox(self, name):
        path = self.dir / f"{name}.jsonl"
        if not path.exists(): return "[]"
        msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
        path.write_text("")  # drain
        return json.dumps(msgs, indent=2)

消息结构(每行一个 JSON 对象)为:

字段 含义
type 消息类型,默认 message
from 发送者名字(Lead 恒为 "lead"
content 正文
timestamp time.time() 时间戳
extra 可选,会被平铺合并进消息体(如 {"request_id": ...},供 s10 协议使用)

相对文档,源码补充了三个值得注意的实现事实:

  1. 类型白名单send() 先校验 msg_type,非法类型返回 Error: Invalid type ... 而不是抛异常——错误以字符串回流给模型,符合课程一贯的“工具错误也是工具输出”风格;
  2. 返回值是给模型看的确认:成功返回 Sent {msg_type} to {to}
  3. broadcast() 是独立方法:对名册中除发送者外的每个成员各发一条 broadcast 类型消息,返回 Broadcast to {count} teammates。它复用的就是 send(),因此广播在磁盘上就是 N 次追加写。

read_inbox() 的 drain 语义是并发正确性的关键:信箱文件天然是“单一消费者队列”,谁读谁清空,消息不会重复投递。代价是它没有锁保护——从源码结构看,s09 假设每个信箱同一时刻只有一个读取者(该队友自己的线程,或 Lead 主循环),这在教学场景下成立;课程后续章节(s13 的 s13_agent_teams/code.py)正是用锁 + Condition 的 wait_for_messages() 把这一点补成了生产级实现。

5. 队友的 agent loop:每次 LLM 调用前先看信箱

队友线程执行的是标准 agent loop(模型 → tool_use → 执行工具 → 回填 tool_result → 直到 stop_reason 非 tool_use),s09 的关键改造是把信箱注入放在循环每一轮的最前面

def _teammate_loop(self, name, role, prompt):
    messages = [{"role": "user", "content": prompt}]
    for _ in range(50):
        inbox = BUS.read_inbox(name)
        if inbox != "[]":
            messages.append({"role": "user",
                "content": f"<inbox>{inbox}</inbox>"})
        response = client.messages.create(...)
        if response.stop_reason != "tool_use":
            break
        # execute tools, append results...
    self._find_member(name)["status"] = "idle"

结合源码可以确认的具体行为(agents/s09_agent_teams.py):

  • 每轮开头 BUS.read_inbox(name) 读取并清空自己的信箱,每条消息以一条 user 角色的 JSON 文本插入 messages——通信不靠轮询提示词,而是自动进入上下文
  • 队友的系统提示词是独立身份:"You are '{name}', role: {role}, at {WORKDIR}. Use send_message to communicate. Complete your task.",与 Lead 的 SYSTEM 提示词分离;
  • max_tokens=8000,循环硬上限 50 轮,防止失控的 tool 循环;
  • 模型调用抛异常时 break 而不是崩溃线程;
  • 循环正常退出(含 break)后,只要状态不是 shutdown,就把成员状态回写为 idle 并落盘——这就是文档表格中 “idle -> working -> idle” 生命周期的来源。

队友侧可用的工具只有 6 个:4 个基础工具(bashread_filewrite_fileedit_file)+ 2 个通信工具(send_messageread_inbox)。也就是说队友不能再 spawn,团队拓扑固定为星型(Lead 为中枢)。

5.1 队友工具的沙箱细节

基础工具实现与 s02 一致(源码注释也明确标注 “unchanged from s02”),但有两层防护值得了解:

  • 路径逃逸防护_safe_path() 把相对路径解析后检查 is_relative_to(WORKDIR),越界直接 ValueError——队友的 read_file/write_file/edit_file 都被限制在工作区内;
  • 危险命令拦截_run_bash()rm -rf /sudoshutdownreboot> /dev/ 做子串黑名单,命令 cwd=WORKDIR、120 秒超时,输出截断到 50000 字符。

6. Lead 侧:9 个工具与信箱驱动的主循环

Lead(主线程)的工具表是 s08 的 6 个基础/后台工具基础上新增 3 个团队工具后的 9 个,通过 TOOL_HANDLERS 字典分发:

工具 作用 底层调用
bash 运行 shell 命令 _run_bash
read_file 读文件(可选 limit) _run_read
write_file 写文件 _run_write
edit_file 精确文本替换 _run_edit
spawn_teammate 生成持久队友(name/role/prompt 必填) TEAM.spawn(...)
list_teammates 列出所有队友及状态 TEAM.list_all()
send_message 向指定队友信箱发消息(msg_type 枚举约束) BUS.send("lead", ...)
read_inbox 读取并清空 Lead 自己的信箱 BUS.read_inbox("lead")
broadcast 群发给所有队友 BUS.broadcast("lead", ...)

注意 spawn_teammate 是 Lead 专属:它只在 Lead 的 TOOLS 表中声明,队友工具表里没有。同时 Lead 的 send_message 固定以 "lead"from,与队友侧以自身名字为发送者形成对称。

Lead 主循环(agent_loop)与队友循环结构相同,差别在信箱注入方式:非空信箱被包成 <inbox>{json}</inbox> 作为一条 user 消息插入历史;工具执行打印 > {tool}: 前缀、截取 200 字符便于终端观察;stop_reason != "tool_use" 时返回,交还终端输入。

6.1 五种消息类型:一次声明,逐步消费

源码顶部定义了 5 种合法消息类型(文档注释原话 “all declared, not all handled here”):

类型 用途 s09 是否处理
message 普通文本消息
broadcast 群发所有队友
shutdown_request 请求优雅关机 声明,s10 实现
shutdown_response 同意/拒绝关机 声明,s10 实现
plan_approval_response 同意/拒绝计划 声明,s10 实现

这个设计很关键:s09 的 VALID_MSG_TYPES 白名单和 extra 字段为 s10 的请求-响应协议request_id 关联 + pending -> approved/rejected 状态机)预留了通道,send() 的签名一个字都不用改。参见 docs/en/s10-team-protocols.md

7. 从 s08 到 s09 的变化

原文档的对比表(完整继承):

Component Before (s08) After (s09)
Tools 6 9 (+spawn/send/read_inbox)
Agents Single Lead + N teammates
Persistence None config.json + JSONL inboxes
Threads Background cmds Full agent loops per thread
Lifecycle Fire-and-forget idle -> working -> idle
Communication None message + broadcast

一句话概括:s08 的线程里跑的是“命令”,s09 的线程里跑的是“完整的 agent”。

8. 动手运行

依赖见 requirements.txtanthropic>=0.25.0python-dotenv>=1.0.0pyyaml>=6.0。运行前需要在 .env 中配置 ANTHROPIC_API_KEYMODEL_ID(源码中 MODEL = os.environ["MODEL_ID"],未设置会直接 KeyError);如走兼容端点可另设 ANTHROPIC_BASE_URL(脚本会在这种情况下主动 popANTHROPIC_AUTH_TOKEN,避免双认证头冲突)。

cd learn-claude-code
python agents/s09_agent_teams.py

进入 s09 >> 提示符后,按原文档给出的剧本依次执行:

  1. Spawn alice (coder) and bob (tester). Have alice send bob a message.
  2. Broadcast "status update: phase 1 complete" to all teammates
  3. Check the lead inbox for any messages
  4. 输入 /team 查看团队名册与各成员状态
  5. 输入 /inbox 手动查看 Lead 信箱(注意:该命令用 read_inbox("lead", False) 只读不清空,不会破坏待注入消息)

运行中可以直接打开工作区的 .team/config.json 观察 status 从 working 翻转为 idle,以及 .team/inbox/*.jsonl 文件的出现、追加与读后清空。退出方式:输入 qexit 或直接回车。

9. 这套设计的演进与边界

s09 是课程 12 步系列里“文件邮箱”的第一次亮相,它的极简取舍值得明确列出(均有源码依据):

  • 无锁的 drain-on-read 依赖“每信箱单读者”假设,跨进程/多写者场景不安全;
  • 50 轮硬上限异常即 break 意味着队友失败是静默的,靠状态回写为 idle 兜底;
  • 无身份重名防护:源码允许任何名字(包括 lead)被 spawn,lead 信箱仍按名册名解析。

这些短板正是课程后话:docs/en/s10-team-protocols.md 在此之上加了带 request_id 的关机和计划审批协议;而重构后的 s13_agent_teams/code.py 把 MessageBus 升级为 .mailboxes/<name>.jsonl + 锁 + Condition.wait() 唤醒、把 lead/agent 设为保留名、把收件箱投递从“模型工具”改为“运行时事件注入”(consume_lead_inbox()),并叠加任务板原子认领与 worktree 绑定。相关回归测试(如信箱路径逃逸拦截、保留名拒绝、shutdown 响应必须来自被请求方)见 tests/test_agent_teams_runtime.py;所有 agents/*.py 脚本的编译级冒烟测试见 tests/test_agents_smoke.py

Agent 团队拓扑

小结:s09 用约 400 行代码给出了多智能体协作的最小可行骨架——config.json 管身份与状态,JSONL 信箱管通信,每个队友一个线程一个完整 agent loop,每次 LLM 调用前自动 drain 信箱进上下文。它没有引入任何消息中间件,只用了文件追加和清空,却完整覆盖了“持久 Agent + 生命周期 + 通信通道”三要素;读懂它,再去看 s10 的协议状态机与 s13 的运行时投递,整条团队 harness 的演进路线就串起来了。

登录后查看全文
热门项目推荐
相关项目推荐