Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第七章:事件总线——AI 团队的内部通讯机制

📌 本章李明轩在做什么:他设计了明轩说的内容流水线——一条稿子要经过"草稿 → 审校 → 定稿 → 排期 → 发布"五个状态。他需要一个机制让每个状态变化自动触发下一环。这就是事件总线。

事件的比喻:一个繁忙的厨房

想象一个餐厅后厨:

  • 服务员把点餐单放到一个共享的架子上
  • 厨师 A 专做冷菜,看到冷菜订单就开始工作,完成后挂上 "冷菜就绪" 的标签
  • 厨师 B 专做热菜,看到 "冷菜就绪" 就知道可以开始配搭
  • 传菜员看到 "出餐就绪" 就把菜端出去

这个 "共享架子" 就是 Ralph 里的事件总线,"订单"、"就绪标签" 就是事件。每个角色(帽子)只关注和自己相关的事件,不需要知道整个厨房在做什么。


帽子和事件的三角关系

这是理解 Ralph 最关键的一张图。每顶帽子在 yaml 里都有两个核心字段:

  • triggers——这顶帽子订阅哪些事件(事件流入)
  • publishes——这顶帽子完成后会发出哪些事件(事件流出)
🎩 某顶帽子 一个 AI 角色 事件 A 事件 B 事件 C 事件 D 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"]

把这些关系连起来,就是一张完整的状态流程图:

⚠️ 人工介入 Telegram / 终端 ralph run 启动 📋 Planner 规划师 ⚙️ Builder 构建者 🧪 Critic 审查者 🏁 Finalizer 终结者 ✅ 完成 build.start tasks.ready review.ready review.passed LOOP_COMPLETE review.rejected(审查打回) finalization.failed(全局未完成,继续改) queue.advance(当前步完,推进下一步) build.blocked happy path 审查 / 终结打回 循环推进 人工介入

读图:四条路径

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.startqueue.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"]
ralph run 启动 🔍 Researcher 研究者 📊 Synthesizer 综合者 ✅ 完成 research.start research.finding RESEARCH_COMPLETE research.followup(还有未答的问题,回去再研究)

读图: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。这样下游只有等合规也过了才会推进。

⚙️ Builder 构建者 🧪 Critic 内置 · 技术审 📜 Compliance 新增 · 合规审 🏁 Finalizer 内置 ⚠️ trigger 改过 ✅ 完成 review.ready review.passed compliance.passed LOOP_COMPLETE review.rejected(技术不过) compliance.failed(合规不过,也要让 Builder 订阅它) ⚠️ 关键改动:把 Finalizer 的 triggers 从 [review.passed] 改成 [compliance.passed], 并把 Builder 的 triggers 补上 compliance.failed,否则合规失败就成孤儿事件。 这两步动了下游 hat 的订阅关系,门禁才真正生效。

对应的 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 每次只能指定一个源。而且 -Hhats: 段会整体替换 -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.startdraft.readydraft.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_eventcompletion_promise 是网络的入口和出口
  • 默认的 code-assist 预设里,4 顶帽子通过 build.start / tasks.ready / review.ready / review.passed / review.rejected / queue.advance / finalization.failed / LOOP_COMPLETE / build.blocked 这 9 个事件连成一张状态机
  • 多顶帽子可以订阅同一个事件——这是插自定义补丁的核心机制
  • 所有事件都写磁盘,循环中断可恢复

李明轩的内容流水线通畅地跑起来了。但他立刻想到一个问题:如果 AI 把一篇有错别字、配图残缺的稿子当成"定稿"发出来,会毁了他的粉丝口碑。他需要硬门禁。下一章讲背压机制——让不合格的内容根本无法通过。