第七章:事件总线——AI 团队的内部通讯机制
📌 本章李明轩在做什么:他设计了明轩说的内容流水线——一条稿子要经过"草稿 → 审校 → 定稿 → 排期 → 发布"五个状态。他需要一个机制让每个状态变化自动触发下一环。这就是事件总线。
事件的比喻:一个繁忙的厨房
想象一个餐厅后厨:
- 服务员把点餐单放到一个共享的架子上
- 厨师 A 专做冷菜,看到冷菜订单就开始工作,完成后挂上 "冷菜就绪" 的标签
- 厨师 B 专做热菜,看到 "冷菜就绪" 就知道可以开始配搭
- 传菜员看到 "出餐就绪" 就把菜端出去
这个 "共享架子" 就是 Ralph 里的事件总线,"订单"、"就绪标签" 就是事件。每个角色(帽子)只关注和自己相关的事件,不需要知道整个厨房在做什么。
帽子和事件的三角关系
这是理解 Ralph 最关键的一张图。每顶帽子在 yaml 里都有两个核心字段:
triggers——这顶帽子订阅哪些事件(事件流入)publishes——这顶帽子完成后会发出哪些事件(事件流出)
加上 event_loop 里的两个锚点:
starting_event——Ralph 启动时自动发出的第一个事件,整个循环从这里开始completion_promise——收到这个事件,Ralph 就认为任务完成,停止循环
整个事件系统就是靠这四个字段拼出来的——triggers / publishes / starting_event / completion_promise。理解了这个三角关系,再去看任何一个预设的 yaml,你都能当场画出它的流程图。
事件的结构
每个在总线上流动的事件都包含以下信息:
事件名称:review.ready ← 发生了什么
来源帽子:builder ← 谁触发的
目标帽子:critic ← 谁来处理(可以是通配符)
时间戳:2026-04-21 10:23:41 ← 什么时候发生的
载荷(Payload): ← 附带的数据
{
"task_id": "task-003",
"task_key": "code-assist:mx:step-02:impl",
"files_changed": ["src/main.py", "tests/test_main.py"],
"summary": "Implemented text conversion with all tests passing"
}
下面一节,我们从真实的预设代码出发,把这套关系在一个具体例子里走一遍。
拆解 code-assist:四顶帽子如何用事件串起来
code-assist 是 Ralph 的默认预设,包含 4 顶帽子。从官方 presets/code-assist.yml 里把每顶帽子的 triggers / publishes 抽出来(指令正文省略):
event_loop:
starting_event: "build.start" # 循环一启动就发这个事件
completion_promise: "LOOP_COMPLETE" # 收到这个事件就结束
hats:
planner:
triggers: ["build.start", "queue.advance"]
publishes: ["tasks.ready"]
builder:
triggers: ["tasks.ready", "review.rejected", "finalization.failed"]
publishes: ["review.ready", "build.blocked"]
critic:
triggers: ["review.ready"]
publishes: ["review.passed", "review.rejected"]
finalizer:
triggers: ["review.passed"]
publishes: ["queue.advance", "finalization.failed", "LOOP_COMPLETE"]
把这些关系连起来,就是一张完整的状态流程图:
读图:四条路径
1. 主路径(灰色实线):ralph run 发出 build.start,触发 Planner;Planner 分解任务后发 tasks.ready,触发 Builder;Builder 写完代码发 review.ready,触发 Critic。这条路径是一次任务默认会走的顺序。
2. Happy path(绿色实线):Critic 审过了,发 review.passed,触发 Finalizer;Finalizer 确认整个任务全部交付,发 LOOP_COMPLETE,Ralph 停止循环。
3. 返工(红色):有两个返工来源——
- Critic 审不过:发
review.rejected(红色实线),触发 Builder(注意 Builder 的 triggers 里正好有review.rejected,这就是事件如何"找到"回去的路径) - Finalizer 判定全局还差事:发
finalization.failed(红色虚线),也是触发 Builder(Builder 同样订阅了它)
4. 循环推进(灰色虚线):Finalizer 判定当前步骤完成但还有后续步骤,发 queue.advance,触发 Planner(Planner 订阅了 build.start 和 queue.advance 两个事件,所以从头来和从中间推进都能工作)。
特殊路径(橙色):Builder 遇到无法自主解决的问题,发 build.blocked,触发人工介入流程(Telegram 推送等)。
关键观察:事件和帽子是多对多的关系
重点看 Builder 那一行:
builder:
triggers: ["tasks.ready", "review.rejected", "finalization.failed"]
Builder 同时订阅 3 个事件。这就是为什么"新任务""审查打回""终结打回"都能让 Builder 开工——它们都是合法的激活入口。
再看 Planner:
planner:
triggers: ["build.start", "queue.advance"]
Planner 订阅"启动"和"推进"两个事件——第一次由 build.start 唤醒,之后每步完成都由 queue.advance 再次唤醒。
这也是为什么"帽子 + 事件"比"函数调用"更有表达力:函数调用只有一个入口,而帽子可以有任意多个入口,只要事件名对得上就会被触发。
对比:research 预设的极简循环
code-assist 有点复杂。再看一个简单的——research 预设,只有 2 顶帽子:
event_loop:
starting_event: "research.start"
completion_promise: "RESEARCH_COMPLETE"
hats:
researcher:
triggers: ["research.start", "research.followup"]
publishes: ["research.finding"]
synthesizer:
triggers: ["research.finding"]
publishes: ["research.followup", "RESEARCH_COMPLETE"]
读图:Researcher 研究完一轮,发 research.finding;Synthesizer 综合后判断——如果还有未答的问题,发 research.followup(触发 Researcher 再研究一轮);如果答完了,发 RESEARCH_COMPLETE,Ralph 停止循环。
两顶帽子,一个往返循环,靠 research.followup 这个事件把"Synthesizer 决定再来一轮"的指令传回给 Researcher——这就是事件总线最纯粹的样子。
时间线:一次循环里事件真实的流动顺序
时间线 ───────────────────────────────────────────────►
t=0 ralph run 发布 build.start
t=30s Planner 激活,读需求,分解任务 → 发 tasks.ready
t=90s Builder 激活,写测试 → 写代码 → 重构 → 发 review.ready
t=150s Critic 激活,复跑测试,挑错 → 发 review.rejected
(Critic 发现测试少了一个边界条件)
t=180s Builder 再次激活(因为订阅了 review.rejected)
补上边界测试和实现 → 发 review.ready
t=240s Critic 再次激活,审查通过 → 发 review.passed
t=270s Finalizer 激活,检查整体 → 发 LOOP_COMPLETE
t=271s Ralph 收到 completion_promise,停止循环
注意 Builder 在 t=90s 和 t=180s 都被激活——第一次由 tasks.ready 触发,第二次由 review.rejected 触发。同一个帽子,不同的触发源,做的事情略有不同(一次是写新代码,一次是修旧代码)——这种行为差异由帽子的 instructions 里根据 <pending event> 判断。
查看实时事件流
Ralph 运行时按 e 键可以看到实时的事件流:
Events History
──────────────────────────────────────────────────────────────
[10:23:00] build.start (source: ralph)
[10:23:31] tasks.ready (source: planner)
payload: {"task_id":"task-001","task_key":"step-01:scaffold"}
[10:25:42] review.ready (source: builder)
payload: {"task_id":"task-001","files":["main.py","test_main.py"]}
[10:27:15] review.rejected (source: critic)
payload: {"reason":"Missing error handling for empty input"}
[10:29:38] review.ready (source: builder)
[10:30:52] review.passed (source: critic)
[10:31:10] queue.advance (source: finalizer)
[10:31:11] tasks.ready (source: planner)
──────────────────────────────────────────────────────────────
也可以在循环结束后用命令回溯:
ralph events # 查看全部事件
ralph events --file <path> # 查看指定 events 文件(每个循环都有自己的)
人工事件注入
Ralph 最强大的功能之一:你可以在循环运行时,手动向事件总线注入事件,实现对循环的实时干预。
# 在另一个终端窗口,向正在运行的循环注入指引
# 语法:ralph emit <事件名> "<载荷>"
ralph emit human.guidance "注意:这个项目不允许使用 requests 库,请改用内置的 urllib"
AI 会在下次迭代时收到这个指引,并调整工作方向。这就是第十二章 "人机协作" 功能的基础:你不需要停掉循环,在恰当的时候发送一个事件,AI 就会 "知道" 你的新要求。
事件与 Telegram
如果你配置了 Telegram 机器人(第十二章详细讲),事件系统会变得更强大:
human.interact:AI 可以通过 Telegram 向你提问,循环暂停等待你回答human.response:你在 Telegram 上的回答会被注入回循环human.guidance:你在 Telegram 上发的任何消息都会成为 AI 的## ROBOT GUIDANCE
自定义事件:给默认流插一顶帽子
假设李明轩用的是 code-assist 预设,但他想加一道额外的"合规审查"关卡——每次 Builder 写完代码,除了 Critic 挑技术错,他还想让另一顶帽子专门检查"是否在日志里泄漏了 API 密钥""是否有 SQL 注入风险"。
先纠正一个陷阱:并行订阅 ≠ 并行门禁
你的第一反应可能是:让 Compliance 也订阅 review.ready,和 Critic 并行跑。这是一个诱人的陷阱——它能跑,但拦不住不合规的代码。
🔬 事件扇出的真实语义(来自 Ralph 源码
event_bus.rs)当
review.ready被发布时,事件总线确实会把它复制到所有订阅者的待办队列里——这点没错。但每次循环迭代只激活一顶帽子(按 hat ID 字母序挑一个),所以 Compliance 和 Critic 实际是依次跑的,不是真正并行。更关键的一点——没有"等所有订阅者都跑完"的内置机制。Critic 一跑完就发
review.passed,Finalizer 订阅的正是这个事件,立刻被触发——哪怕下一次迭代 Compliance 才开始跑,哪怕 Compliance 最后发了compliance.failed,Finalizer 根本不看那个事件。
aggregate: wait_for_all字段是为"一顶帽子开 N 个并行实例"(波浪机制,第十一章)设计的,不是用来做"多顶帽子的 AND 门"。
所以"让 Compliance 也订阅 review.ready"这种改法,Compliance 只能做旁观式日志 / 事后通知,不能真的拦下任何东西。要真的门禁,必须把 Compliance 串进事件流里。
正确做法:串行插入 + 改下游 triggers
让 Compliance 订阅 Critic 发出的 review.passed,做完合规后发一个新事件 compliance.passed,然后把 Finalizer 的 triggers 从 review.passed 改成 compliance.passed。这样下游只有等合规也过了才会推进。
对应的 yaml:
hats:
# Critic 保持内置不变
critic:
triggers: ["review.ready"]
publishes: ["review.passed", "review.rejected"]
# 新增:Compliance 串在 Critic 之后
compliance:
triggers: ["review.passed"] # ← 只在技术审过之后才跑
publishes: ["compliance.passed",
"compliance.failed"]
instructions: |
你是合规审查者。检查:
1. 日志 / 错误信息里有没有泄漏密钥、token、密码
2. 用户输入有没有 SQL / 命令注入风险
3. 敏感字段是否加密
全过发 compliance.passed;任一不过发 compliance.failed。
# 关键改动 ①:Finalizer 改成等合规事件
finalizer:
triggers: ["compliance.passed"] # ← 原本是 review.passed,改了
publishes: ["queue.advance", "finalization.failed", "LOOP_COMPLETE"]
# 关键改动 ②:Builder 要订阅 compliance.failed,不然合规不过就没人接
builder:
triggers: ["tasks.ready", "review.rejected",
"finalization.failed", "compliance.failed"]
publishes: ["review.ready", "build.blocked"]
什么时候"并行订阅"才有意义
如果新帽子只做日志 / 统计 / 通知(不拦流程),那并行订阅就很合适。比如:
hats:
stats_logger:
triggers: ["review.ready", "review.passed", "review.rejected"]
publishes: [] # 不发任何事件,纯粹旁观
instructions: |
把每次 review 事件的时间戳和结果写进 .ralph/review-stats.jsonl,用来后续分析。
这种"旁观者"帽子不需要下游等它,因为它根本不是决策链的一环——它是挂在事件总线上的一个 tap(取样点)。区分"旁观者"还是"门禁",是设计自定义帽子的第一件事。
加载方式的真相:-H 不能叠加
另一个容易踩坑的地方——-H 每次只能指定一个源。而且 -H 的 hats: 段会整体替换 -c 里的 hats: 段,不是逐帽子合并。所以你不能这样"打补丁":
# ❌ 这样写不会"code-assist + compliance"叠加——
# -H 只接受一个值,多余的 -H 会被丢弃;即使生效,后面的也会整体替换前面的 hats 段
ralph run -H builtin:code-assist -H .ralph/hats/compliance.yml -p "..."
正确做法是把内置预设 fork 到本地,在本地文件里直接改:
# 第一次:下载内置 preset 作为起点
curl -L -o .ralph/hats/mx-review.yml \
https://raw.githubusercontent.com/mikeyobrien/ralph-orchestrator/main/presets/code-assist.yml
# 然后打开 .ralph/hats/mx-review.yml,按上面那段 yaml 加 compliance 帽 + 改 finalizer / builder 的 triggers
# 启动时只用这一个 -H
ralph run -c ralph.yml -H .ralph/hats/mx-review.yml -p "..."
进阶:完全自定义的事件链
你也可以不沿用内置事件名,从头发明自己的事件链,约定用 主题.动作 格式:
event_loop:
starting_event: "content.start"
completion_promise: "CONTENT_PUBLISHED"
hats:
writer:
triggers: ["content.start"]
publishes: ["draft.ready"]
reviewer:
triggers: ["draft.ready"]
publishes: ["draft.approved", "draft.rejected"]
publisher:
triggers: ["draft.approved"]
publishes: ["CONTENT_PUBLISHED"]
这里的 content.start、draft.ready、draft.approved 都是自创的——Ralph 不关心事件叫什么,只关心谁订阅了它、谁会发出它。事件名是帽子之间的"约定接口",想怎么命名都可以。
命名的实用建议:
- 用
.分隔主题和动作(draft.approved而不是draft_approved)- 动作尽量用过去分词或形容词(
ready/passed/approved/failed),读起来像"某件事刚发生"- 全大写保留给终止信号(
LOOP_COMPLETE/RESEARCH_COMPLETE/CONTENT_PUBLISHED)——让 Ralph 一眼看出这是出口事件
事件的可靠性保证
一个常见问题:如果 AI 崩溃或超时,事件会丢吗?
不会。Ralph 把所有事件持久化保存在 .ralph/events*.jsonl 里。如果循环意外中断,重启时 Ralph 会从磁盘把上次的事件流读回来,从中断处继续,而不是从头来过。
这正是"磁盘是状态,Git 是记忆"法则的具体体现——事件总线的全部历史都可以在磁盘上追溯,没有任何"只在内存里"的临时状态。
本章小结
- 帽子和事件是多对多的关系——每顶帽子订阅多个事件,发布多个事件,事件通过
triggers/publishes把帽子连成网络 event_loop.starting_event和completion_promise是网络的入口和出口- 默认的
code-assist预设里,4 顶帽子通过build.start/tasks.ready/review.ready/review.passed/review.rejected/queue.advance/finalization.failed/LOOP_COMPLETE/build.blocked这 9 个事件连成一张状态机 - 多顶帽子可以订阅同一个事件——这是插自定义补丁的核心机制
- 所有事件都写磁盘,循环中断可恢复
李明轩的内容流水线通畅地跑起来了。但他立刻想到一个问题:如果 AI 把一篇有错别字、配图残缺的稿子当成"定稿"发出来,会毁了他的粉丝口碑。他需要硬门禁。下一章讲背压机制——让不合格的内容根本无法通过。