第3章 事件驱动与消息模型
2026.07.29Agent 内核不是"用户问一句,agent 答一句"那么简单——它需要同时处理多个消息来源、响应取消指令、管理子代理生命周期。
模式层
3.1 Agent 内核的两种范式
Agent 内核的架构选择决定了整个系统的行为特征。如第 1 章所述,agent 内核经历了 REPL 循环、事件驱动、微型操作系统三种范式的演进。第 1 章从全景视角阐述了三种范式的特点,本章从实现细节展开讨论事件驱动模型。
事件驱动——灵活但复杂。
事件驱动内核把 REPL 的"四步循环"替换为"事件循环"。内核不再阻塞等待一个输入来源——它监听多个来源(用户消息、工具结果、系统信号),根据事件类型做出响应。
事件驱动不是新增功能——它改变了 agent 与外部世界的交互模型。在 REPL 下,agent 是"被动的响应者"——用户说什么它做什么。在事件驱动下,agent 可以"主动发起"——它可以在工具执行期间检查取消信号,可以在子代理返回之前继续处理其他事件。
代价是调试复杂度。REPL 的调试只需要看输入输出,事件驱动需要追踪消息的时序和来源。"这个事件为什么先于另一个事件到达"——这在 REPL 下不是问题,因为它们是顺序执行的。
请求-响应——中间态。
在 REPL 和事件驱动之间还有一种常见形态:请求-响应。用户发送一条请求,agent 处理,返回响应。它与 REPL 的区别在于:请求-响应通常支持流式输出(SSE,Server-Sent Events / WebSocket),而 REPL 的输出是整块的。但请求-响应和 REPL 一样不支持取消和嵌套。
三种范式各有适用场景:
| 范式 | 适合 | 不适合 |
|---|---|---|
| REPL 循环 | 单轮问答、短任务 | 多步任务、需要取消 |
| 事件驱动 | 多步任务、交互式工作 | 单轮问答(过度设计) |
| 请求-响应 | 并行处理、流式输出 | 需要持久化状态 |
3.2 OS 线程 + channel 而不是 async runtime
在确定了事件驱动方向后,下一个问题是:用 async runtime(tokio / async-std)还是 OS 线程 + channel?
选 tokio 的理由很充分:它是 Rust 生态中最成熟的异步运行时,社区认可度高,大量第三方库支持。大部分 Rust agent 项目都选 tokio。
但 CodeCoder 最终选了 OS 线程 + channel。三个原因:
第一,工具调用是阻塞的。
Agent 的工具调用——read_file、run_command、glob、web_fetch——大多是同步阻塞操作。在 async runtime 里,一个阻塞操作会阻塞整个事件循环,所有其他任务都要等它。要让工具调用变成非阻塞,你需要为每个工具提供 async API——这在实践中非常困难。不是每个第三方库都提供 async 接口。std::process::Command 是同步的,std::fs::read 是同步的,std::net::TcpStream 也是同步的。
OS 线程没有这个问题。一个线程可以阻塞在 read_file 上,其他线程继续运行。
第二,子 agent 需要独立阻塞。
父 agent 通过 agent 工具创建一个子 agent 时,父 agent 需要等待子 agent 返回结果。在 async runtime 里,这个等待应该通过 await 实现——但父 agent 的 turn 循环本身可能不在 async 上下文中。混合同步和异步代码是 Rust 中一个出了名的难点。
OS 线程的解决方案是:父 agent 在线程 A 上运行,子 agent 在线程 B 上运行。父 agent 调用 agent 工具时,在线程 A 上阻塞在 channel receive 上,等待线程 B 的返回。这个阻塞不会影响其他线程。
第三,取消路径更清晰。
Async runtime 的取消通常通过 abort() 或 drop() 实现。但 abort() 不能保证子进程的正确清理——如果子 agent 正在跑一个 run_command,abort() 只是丢弃了 future,子进程可能变成孤儿进程。
OS 线程的取消路径是:翻转共享的 CancelToken,工具执行循环在安全点检查这个 token。如果被翻转,先优雅终止子进程,再返回"已取消"状态。取消路径中的每一步都是显式的、可追踪的。
代价是上下文切换成本。OS 线程的上下文切换比 async task 的 yield 成本高一个数量级。但对于 CodeCoder 的负载——agent 的工具调用次数通常是几十到几百次/天,不是百万次/秒——这个成本可以忽略。
3.3 双通道拓扑与 Provider 中立
事件驱动内核需要一个消息模型来在组件之间传递信息。CodeCoder 的设计是双通道拓扑:
graph LR
subgraph "命令通道 cmd_tx"
PM[ProcessMessage<br/>用户新消息]
SD[Shutdown<br/>关闭]
CN[Cancel<br/>取消]
end
subgraph "事件通道 event_rx"
NT[NewToken<br/>流式 token]
TS[ToolStarted<br/>工具开始执行]
TF[ToolFinished<br/>工具执行完成]
SU[StatusUpdate<br/>状态变更]
DN[Done<br/>turn 完成]
end
subgraph "特殊通道 reply_tx"
AK[AskUser<br/>询问用户]
CF[ConfirmRequest<br/>确认请求]
RP[用户回复<br/>→ oneshot]
end
PM -->|低流量| AGENT
SD -->|低流量| AGENT
CN -->|低流量| AGENT
AGENT -->|高流量| NT
AGENT -->|中流量| TS
AGENT -->|中流量| TF
AGENT -->|低流量| SU
AGENT -->|低流量| DN
AGENT -->|需要确认| AK
AGENT -->|需要确认| CF
AK --> RP
CF --> RP
RP -->|直接进入工具执行器| AGENT
style PM fill:#e1f5fe
style SD fill:#e1f5fe
style CN fill:#e1f5fe
style NT fill:#f3e5f5
style TS fill:#f3e5f5
style TF fill:#f3e5f5
style AK fill:#fff3e0
style CF fill:#fff3e0
style RP fill:#fff3e0
命令通道 (cmd_tx):只传递用户的主动意图。三个变体:
ProcessMessage:用户发送了一条新消息Shutdown:用户请求关闭Cancel:用户请求取消当前操作
事件通道 (event_rx):传递流式增量和结构化状态。五个主要变体:
NewToken:LLM 生成的新 token(流式输出)ToolStarted:工具开始执行ToolFinished:工具执行完成(含结果或错误)StatusUpdate:agent 状态变更(如"正在思考"、"正在执行工具")Done:当前 turn 完成
两个通道的分离是关键设计。命令通道的流量极低(只有用户主动触发的指令),事件通道的流量可以很高(每个 token 都是事件)。如果混用一个通道,取消指令可能在 token 事件流后面排队——用户按了 Ctrl+C,但 agent 要继续输出几百个 token 后才能收到取消指令。
除了通道拓扑,turn 的完整生命周期也遵循事件驱动模式。从用户发送消息到 agent 自主决定输出完成,整个流程如下:
sequenceDiagram
participant User as 用户
participant Cmd as cmd_tx
participant Loop as AgentLoop
participant LLM as LLM Provider
participant Tool as Tool Executor
participant Evt as event_rx
participant Client as 客户端
User->>Cmd: 发送消息
Cmd->>Loop: ProcessMessage
Loop->>Loop: build_request<br/>(拼接 system prompt + 历史 + 当前消息)
Loop->>LLM: provider.send()
LLM-->>Loop: 流式响应
loop 处理 LLM 响应流
LLM-->>Loop: NewToken
Loop->>Evt: NewToken
Evt->>Client: 显示 token
LLM-->>Loop: ToolCall
Loop->>Evt: ToolStarted
Evt->>Client: 显示工具执行中
Loop->>Loop: 检查 CancelToken
alt 已取消
Loop->>Evt: ToolFinished { cancelled }
Evt->>Client: 显示已取消
Loop->>Evt: Done { cancelled }
else 未取消
Loop->>Tool: execute()
Tool-->>Loop: ToolResult
Loop->>Evt: ToolFinished { result }
Evt->>Client: 显示工具结果
end
end
LLM-->>Loop: Done
Loop->>Evt: Done { cancelled: false }
Evt->>Client: 显示完成
Note over Loop: 如果 LLM 在最后一次回复中<br/>又调用了新工具,<br/>回到"处理 LLM 响应流"阶段
Provider 中立意味着内核的消息模型不绑定任何 LLM provider。在 CodeCoder 中,内核定义了自己的消息类型:
Message:一条完整的对话消息(role + items)MessageItem:消息的组成部分——Text、Reasoning、ToolCall、ToolResultToolCall:工具调用请求(name + args + id)ToolResult:工具调用结果(content + status + tool_call_id)
内核与 provider 之间通过一个 ProviderClient trait 隔离。切换 provider 不需要修改内核代码——只需要实现一个新的 ProviderClient。这个设计在 ADR 0017 中固化。
案例层
3.4 AgentLoop::process_turn 核心流程
以下伪代码展示了 AgentLoop::process_turn 的核心流程:
fn process_turn(&mut self) -> Result<AgentEvent> {
// 1. 从 cmd_tx 接收用户消息
let msg = self.cmd_rx.recv()?;
// 2. 构建 LLM 请求(含 system prompt、历史、当前消息)
let request = self.build_request(msg);
// 3. 发送请求到 LLM provider,流式接收响应
let response = self.provider.send(request)?;
// 4. 处理 LLM 的响应流
for event in response.stream() {
match event {
StreamEvent::Token(t) => self.event_tx.send(AgentEvent::NewToken(t)),
StreamEvent::ToolCall(tc) => {
// 5. 检查取消 token
if self.cancel_token.is_cancelled() {
self.event_tx.send(AgentEvent::ToolFinished {
id: tc.id,
result: Err("cancelled".into()),
});
return Ok(AgentEvent::Done { cancelled: true });
}
// 6. 执行工具
self.event_tx.send(AgentEvent::ToolStarted { id: tc.id });
let result = self.toolbox.execute(&tc);
self.event_tx.send(AgentEvent::ToolFinished {
id: tc.id,
result,
});
}
StreamEvent::Done => break,
}
}
// 7. 通知完成
self.event_tx.send(AgentEvent::Done { cancelled: false });
Ok(AgentEvent::Done { cancelled: false })
}
这个流程的关键点在于:
- 步骤 5 的取消 token 检查:工具执行循环在每次迭代中检查 token,而不是在整个响应流处理完后才检查。
CancelToken的完整设计和取消路径见第 5 章 - 步骤 6 的工具执行是串行的:一个 ToolCall 完成后再处理下一个,不并行
- 步骤 2 的
build_request处理了 system prompt 的拼接——包括 AGENTS.md、CONTEXT.md、skills/ 常驻目录的内容
3.5 Message / MessageItem 类型设计
Message 和 MessageItem 是内核消息模型的核心类型:
struct Message {
role: Role, // User | Assistant | Tool | System
items: Vec<MessageItem>,
id: MessageId, // u64,session 内唯一
}
enum MessageItem {
Text(String),
Reasoning(String),
ToolCall(ToolCall),
ToolResult(ToolResult),
}
struct ToolCall {
id: ToolCallId, // provider 侧的 tool_use id
name: String,
args: serde_json::Value,
}
struct ToolResult {
tool_call_id: ToolCallId,
content: Vec<ContentItem>,
status: ToolResultStatus, // Success | Error | Cancelled
}
MessageItem 的多态设计允许一条 assistant 消息包含多个工具调用和多个文本段——这在 LLM 同时输出文本和工具调用时很重要。ToolCall.id 和 ToolResult.tool_call_id 的关联由 provider 确保——这是一个不在内核中处理的不变量。
3.6 AgentCommand / AgentEvent 变体
命令通道和事件通道的枚举变体如下:
enum AgentCommand {
ProcessMessage(Message),
Shutdown,
Cancel,
}
enum AgentEvent {
NewToken(String),
ToolStarted { id: ToolCallId },
ToolFinished {
id: ToolCallId,
result: Result<ToolResult, ToolError>,
},
StatusUpdate(AgentStatus),
Done { cancelled: bool },
}
AgentCommand 只包含用户意图。AgentEvent 包含所有流式增量。注意 ToolFinished 中 result 的类型是 Result<ToolResult, ToolError>——工具执行失败时(如权限拒绝),ToolResult 不会生成,而是直接返回 ToolError。这个设计使工具调用失败的处理路径不同于工具执行成功但结果内容有问题的路径。
3.7 turn 生命周期
一个完整的 turn 从用户发送消息到 agent 决定不再继续调用工具,包含以下阶段:
用户发送消息
↓
cmd_tx → AgentLoop 接收
↓
build_request → 拼接 system prompt + 历史 + 当前消息
↓
provider.send() → LLM 流式响应
↓
[循环] 处理 LLM 响应流
├→ NewToken → event_tx → 客户端展示
├→ ToolCall → 执行工具 → event_tx → 客户端展示
│ ├→ 检查 cancel_token,如果取消则返回
│ └→ 工具结果 → 加入工具调用历史
└→ Done → 跳出循环
↓
[条件] 如果 LLM 在最后一次回复中又调用了新工具
↓
回到 "处理 LLM 响应流" 阶段
↓
[无新工具调用] → 完成当前 turn → 等待下一轮
多轮工具调用(LLM 连续调用多个工具、每次结果喂回去)是 agent 的工作常态——不是"一次响应中调多个工具",而是"一次响应调一个工具 → 结果喂回去 → 再调下一个"的循环。这个循环由 LLM 自主决定是否继续(向 LLM 的请求中包含了所有历史工具调用结果)。
3.8 reply_tx oneshot
事件通道中有一个特殊的机制:reply_tx oneshot channel。
当 agent 执行一个需要用户确认的工具(如 ask_user 或 confirm)时,工具执行器会创建一个 oneshot channel,通过 AgentEvent::AskUser 或 AgentEvent::ConfirmRequest 发送给客户端。客户端在终端显示用户 prompt,收集用户输入,通过 reply_tx 发送回复。
这个设计的关键点在于:用户确认的回复不走 cmd_tx。
cmd_tx 是用于"用户主动意图"的通道——用户主动发送消息、主动要求关闭、主动取消。用户对 agent 提问的回复不是"主动意图",而是"对 agent 请求的响应"。如果走 cmd_tx,cmd_tx 的接收端需要区分"用户主动发来的新消息"和"用户对 agent 上一个问题的回复"——这增加了状态管理的复杂性。
reply_tx 的 oneshot 设计避免了这个问题:工具执行器在发出确认请求后,阻塞在 reply_tx 的 receive 上。用户回复直接进入工具执行器,不经过 cmd_tx。cmd_tx 的专注性得以保持——它只处理用户的主动意图。
ADR 深度阅读
tokio / lunatic 被否的历史
ADR 0016 记录了 CodeCoder 在"如何构建内核"这一问题上的决策过程。
最初的原型使用 tokio 作为异步运行时。原因很简单:社区标准、文档丰富、生态成熟。但经过几个月的开发,三个问题暴露出来:
工具栈的异步污染。 每个工具都需要实现
async fn execute()。如果一个工具内部调用了一个同步的第三方库,你需要用tokio::task::spawn_blocking把它包起来——这增加了每个工具的样板代码量。CodeCoder 的 26 个工具中,大部分是同步的。子 agent 的取消问题。 当一个子 agent 在 tokio task 中运行时,取消它需要
tokio::task::abort()。但abort()只丢弃了 future——如果子 agent 正在执行run_command,子进程不会随 future 的丢弃而被终止。这导致了孤儿进程。调试困难。 Tokio 的 task 栈追踪在出现问题时不够清晰——"哪个 task 起源于哪个请求"在多层嵌套 task 中不易追踪。
另一个被考虑过的选项是 lunatic——一个基于 Erlang actor 模型的 Rust 运行时。Lunatic 的 actor 模型提供了进程级别的隔离,每个 actor 有自己的内存空间,崩溃不会影响其他 actor。但 lunatic 的生态远不如 tokio 成熟,且它的 actor 模型与 Rust 的所有权系统存在摩擦——actor 之间传递数据需要序列化/反序列化。
最终决定放弃 async runtime,回到 OS 线程 + channel。这不是一个"tokio 不好"的声明——它是一个"对于我们的工具负载特性,OS 线程更匹配"的工程决策。
PermissionResponse 的历史
PermissionResponse 是否应该作为 AgentCommand 的一个变体,是 CodeCoder 早期的一个设计争议。
最初的实现中,PermissionResponse 是 AgentCommand 的一个变体——用户对权限确认的回复通过 cmd_tx 发送给 agent 线程。这样做的理由是:保持所有用户→agent 的通信都走同一个通道。
问题在于:cmd_tx 的接收端(AgentLoop)在处理 AgentCommand 时,需要区分"一条新消息"和"对一个权限请求的回复"。如果 PermissionResponse 和 ProcessMessage 走同一个通道,接收端需要维护一个"当前是否有 pending 的权限请求"的状态机。
把这个状态机从 AgentLoop 中移除到 reply_tx oneshot 中,简化了 AgentLoop 的 cmd_tx 处理逻辑——cmd_tx 的接收端只做三件事:处理新消息、关闭、取消。不需要知道"当前有没有一个 pending 的权限请求"。
下一章进入工具体系与权限模型——26 个工具的粒度和四级权限的查找链。