learn-claude-code s09 实战:用持久队友与文件邮箱构建 Agent Teams
在 learn-claude-code(一个从 0 到 1 构建 nano 版 Claude Code 风格 agent harness 的课程仓库)中,s09 章节回答了一个多智能体协作的核心问题:当任务大到单个 Agent 扛不住时,如何生成有身份、有生命周期、能互相通信的“队友”。本篇完整拆解 s09 的 TeammateManager 与 MessageBus 两套核心机制——基于 append-only JSONL 文件信箱、线程承载的独立 agent loop、以及 Lead 侧 9 个工具的调度设计,并对照课程源码给出可直接运行的实操步骤,帮助你掌握“文件即通信总线”这一轻量协作模式。
1. 为什么需要持久队友:s04 与 s08 留下的空白
s09 的出发点是两个已有机制都不够用:
- Subagent(s04/s06)是一次性的:spawn → 执行 → 返回摘要 → 销毁。没有身份,两次调用之间不保留任何记忆;
- 后台任务(s08)只会跑 shell 命令:它能执行长时间命令,但无法做 LLM 主导的决策。
真正的团队协作需要三样东西,s09 逐一补齐:
- 跨提示词存活的持久 Agent(outlive a single prompt);
- 身份与生命周期管理(名字、角色、状态);
- 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 取值有 working、idle、shutdown(文档与 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)
这带来两个可验证的行为事实:
- 对一个
working中的队友重名 spawn 会直接报错而不是开第二个线程; - 对
idle/shutdown队友重名 spawn 会把它原地复活为working,并挂上一个全新线程、全新的 messages 历史(注意:名册只保留状态,旧对话历史不会恢复); - 线程统一登记在
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 协议使用) |
相对文档,源码补充了三个值得注意的实现事实:
- 类型白名单:
send()先校验msg_type,非法类型返回Error: Invalid type ...而不是抛异常——错误以字符串回流给模型,符合课程一贯的“工具错误也是工具输出”风格; - 返回值是给模型看的确认:成功返回
Sent {msg_type} to {to}; 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 个基础工具(bash、read_file、write_file、edit_file)+ 2 个通信工具(send_message、read_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 /、sudo、shutdown、reboot、> /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.txt:anthropic>=0.25.0、python-dotenv>=1.0.0、pyyaml>=6.0。运行前需要在 .env 中配置 ANTHROPIC_API_KEY 和 MODEL_ID(源码中 MODEL = os.environ["MODEL_ID"],未设置会直接 KeyError);如走兼容端点可另设 ANTHROPIC_BASE_URL(脚本会在这种情况下主动 pop 掉 ANTHROPIC_AUTH_TOKEN,避免双认证头冲突)。
cd learn-claude-code
python agents/s09_agent_teams.py
进入 s09 >> 提示符后,按原文档给出的剧本依次执行:
Spawn alice (coder) and bob (tester). Have alice send bob a message.Broadcast "status update: phase 1 complete" to all teammatesCheck the lead inbox for any messages- 输入
/team查看团队名册与各成员状态 - 输入
/inbox手动查看 Lead 信箱(注意:该命令用read_inbox("lead", False)只读不清空,不会破坏待注入消息)
运行中可以直接打开工作区的 .team/config.json 观察 status 从 working 翻转为 idle,以及 .team/inbox/*.jsonl 文件的出现、追加与读后清空。退出方式:输入 q、exit 或直接回车。
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。
小结:s09 用约 400 行代码给出了多智能体协作的最小可行骨架——config.json 管身份与状态,JSONL 信箱管通信,每个队友一个线程一个完整 agent loop,每次 LLM 调用前自动 drain 信箱进上下文。它没有引入任何消息中间件,只用了文件追加和清空,却完整覆盖了“持久 Agent + 生命周期 + 通信通道”三要素;读懂它,再去看 s10 的协议状态机与 s13 的运行时投递,整条团队 harness 的演进路线就串起来了。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00