Rust AI Agent 架构设计:事件驱动还是请求响应,先画个时序图
今天想跟大家聊聊我在设计 Agent 架构时遇到的一个核心选择:到底用事件驱动还是请求响应?我先说结论:两者不是互斥的,而是要看场景搭配使用。下面我会用一个具体的例子,从时序图开始,一步步拆解我的思考过程。
一、从最简单的请求响应开始
一开始我写的 Agent 就是最朴素的请求响应模式。用户发一条消息,Agent 调用大模型,拿到回复,结束。代码大概长这样:
/// Agent 的核心执行流程 —— 最简版本
/// 用户输入 -> LLM调用 -> 返回结果,一条路走到黑
async fn run_agent_simple(user_input: &str) -> Result<String, AgentError> {
// 1. 构造 prompt(把用户的话包装一下)
let prompt = format!(
"你是一个助手,请回答用户的问题:\n{}",
user_input
);
// 2. 调用大模型,等待返回
let response = llm_client
.chat(&prompt)
.await
.map_err(|e| AgentError::LlmError(e.to_string()))?;
// 3. 直接返回结果,不做额外处理
Ok(response)
}
这结构简单到不能再简单了,三步骤:构造 prompt → 调 LLM → 返回。我当时觉得:Agent 不就是这样吗?
但问题很快就来了。比如我想让 Agent 调用一个工具(比如查天气),那流程就变成了:
sequenceDiagram
participant User as 用户
participant Agent as Agent核心
participant LLM as 大模型
participant Tool as 工具(查天气)
User->>Agent: "今天北京天气怎么样?"
Agent->>LLM: 发送prompt + 工具列表
LLM-->>Agent: 返回 tool_call(查天气, 北京)
Agent->>Tool: 调用天气API
Tool-->>Agent: 返回天气数据
Agent->>LLM: 把天气数据作为上下文,再请求LLM
LLM-->>Agent: 返回最终回答
Agent-->>User: "今天北京晴,25°C"
你看出问题了吗?这个流程是串行阻塞的。每一步都必须等上一步完成。一个简单的天气查询,用户要等两轮 LLM 调用 + 一次 API 调用。如果网络慢了,整个流程就卡住了。
二、引入事件驱动:让 Agent 学会并发
后来我读到了一些关于事件驱动架构的文章,突然意识到:Agent 的很多步骤其实可以并行。比如:
- 调用 LLM 的同时,可以先准备好下一步可能用到的上下文
- 多个工具调用如果彼此独立,可以并发执行
- 流式输出(streaming)本身就是事件驱动的
我重新设计了一个事件驱动的 Agent 内核,核心思路是:把 Agent 的每一步都抽象成一个事件(Event),用一个事件循环来驱动整个流程。
use std::collections::HashMap;
use tokio::sync::mpsc;
/// 定义 Agent 内部的事件类型
/// 每种事件代表一种需要处理的情况
#[derive(Debug, Clone)]
enum AgentEvent {
/// 用户新输入
UserInput(String),
/// LLM 返回了需要调用工具
ToolCallRequired {
tool_name: String,
arguments: HashMap<String, serde_json::Value>,
},
/// 工具执行完毕,返回结果
ToolResult {
tool_name: String,
result: String,
},
/// LLM 生成了一段文本(流式)
StreamChunk(String),
/// LLM 返回了最终完整回答
FinalResponse(String),
/// 发生了错误
Error(String),
}
/// 事件驱动的 Agent 核心
/// 用一个 channel 接收各类事件,在循环中统一处理
struct EventDrivenAgent {
/// 事件接收端 —— 各种异步任务把事件发到这里
event_rx: mpsc::UnboundedReceiver<AgentEvent>,
/// 当前对话的上下文消息列表
messages: Vec<String>,
/// 待执行的工具调用队列
pending_tool_calls: Vec<(String, HashMap<String, serde_json::Value>)>,
}
impl EventDrivenAgent {
/// 主事件循环:不断地从 channel 中取事件并处理
async fn run(&mut self) {
while let Some(event) = self.event_rx.recv().await {
match event {
AgentEvent::UserInput(text) => {
// 收到用户输入 → 启动LLM调用
self.messages.push(format!("用户: {}", text));
self.dispatch_llm_call().await;
}
AgentEvent::ToolCallRequired { tool_name, arguments } => {
// LLM要求调工具 → 加入待处理队列
println!("[Agent] 需要调用工具: {}", tool_name);
self.pending_tool_calls.push((tool_name, arguments));
// 如果只有单个工具调用,也可以直接在这里触发异步执行
// 多个工具调用可以并发处理
}
AgentEvent::ToolResult { tool_name, result } => {
// 工具返回结果 → 追加到对话上下文
self.messages.push(
format!("工具[{}]返回: {}", tool_name, result)
);
// 检查是否所有工具都执行完了
// 全部完成后,继续下一轮LLM调用
if self.pending_tool_calls.is_empty() {
self.dispatch_llm_call().await;
}
}
AgentEvent::StreamChunk(chunk) => {
// 流式输出:来一段发一段
print!("{}", chunk);
}
AgentEvent::FinalResponse(text) => {
// 最终回答:结束本轮对话
self.messages.push(format!("助手: {}", text));
println!("\n[Agent] 最终回答已生成");
break;
}
AgentEvent::Error(e) => {
eprintln!("[Agent] 错误: {}", e);
break;
}
}
}
}
/// 向 LLM 发起一次调用请求
/// 实际项目中这里会通过 event_tx 把流式结果发回来
async fn dispatch_llm_call(&self) {
// 具体的 LLM 调用逻辑...
// 调用过程中会产生 StreamChunk → FinalResponse 事件流
println!("[Agent] 向LLM发送请求,当前消息数: {}", self.messages.len());
}
}
这个设计的核心变化是:控制流不再是一条直线,而是一个循环。事件循环不断接收各种事件,根据不同的事件类型做不同的事。好处也很明显:
- 流式输出天然支持:StreamChunk 事件来了就输出,不用等全部生成完
- 工具并发调用:多个工具调用可以同时发出,各自完成后通过 ToolResult 事件回报
- 扩展性好:新增一种事件类型,只需在 match 里加一条分支,不需要改动整体流程
三、混合架构:实践中我的选择
说实话,在实际项目中,我并没有完全抛弃请求响应模式。我发现混合架构是更务实的做法:
flowchart TB
subgraph 外层["外层:请求响应(同步等待)"]
A[用户输入] --> B{是否需要推理?}
B -->|是| C[进入事件循环]
B -->|否| D[直接返回]
end
subgraph 内层["内层:事件驱动(异步处理)"]
C --> E[LLM调用]
E --> F{返回类型?}
F -->|文本| G[StreamChunk事件]
F -->|工具调用| H[并发调度工具]
H --> I[ToolResult事件]
G --> J{生成完毕?}
J -->|否| E
J -->|是| K[FinalResponse事件]
I --> E
end
K --> L[外层返回结果]
D --> L
这个混合架构的思路是:
- 外层保持同步语义:对于调用方来说,
agent.run(input)仍然返回一个Future,await 之后就能拿到结果。这样上层代码不需要关心底层是请求响应还是事件驱动。 - 内层使用事件驱动:在 Agent 内部,LLM 调用、工具执行、流式输出都通过事件来协调,充分利用异步并发的优势。
/// 混合架构的 Agent 对外接口
/// 对外表现为简单的请求响应,内部使用事件驱动
impl Agent {
/// 对调用者来说,这就是一个简单的 async 函数
/// 但内部是完整的事件循环驱动
pub async fn chat(&mut self, user_message: &str) -> Result<String, AgentError> {
// 启动内部事件循环,传入用户消息
let (result_tx, result_rx) = oneshot::channel();
// 把用户输入作为一个事件发送
self.event_tx.send(AgentEvent::UserInput(user_message.to_string()))
.map_err(|_| AgentError::ChannelClosed)?;
// 启动事件循环去处理(内部是事件驱动)
self.run_event_loop().await?;
// 等待结果返回(请求响应语义)
result_rx.await.map_err(|_| AgentError::ChannelClosed)?
}
}
四、实践中的坑和心得
说到底,架构选择没有绝对的对错。我作为一个半路出家的自学程序员,踩过的坑总结几点:
别过早优化:一开始就上事件驱动,代码复杂度翻倍,但你根本不知道瓶颈在哪。先用请求响应把功能跑通,跑通了再去 profile,看哪里慢。
事件驱动不等于高性能:如果你只有 1 个 LLM 调用和 1 个工具调用,而且它们是串行依赖的,事件驱动不会带来任何性能提升,反而增加了代码的理解成本。
状态的正确性是最大的挑战:事件驱动的代码最难的是管理状态。当 ToolResult 到达时,你怎么知道它对应的是哪次请求?我推荐给每个事件带上一个
request_id。多读读 Tokio 的源码:实际上 Tokio 的 scheduler 本身就是事件驱动的典范。理解了
tokio::select!和 channel 的用法,事件驱动的 Agent 写起来会顺手很多。
五、总结
这篇文章从时序图出发,对比了请求响应和事件驱动两种 Agent 架构模式。我个人在实践中更倾向于混合架构——对外暴露简洁的请求响应接口,内部用事件驱动来协调 LLM 调用、工具执行和流式输出。
对于和我一样的自学转码选手,我的建议是:先理解透彻一个模式,再考虑要不要混用。不要因为"别人都说事件驱动好",就在项目初期引入过多复杂度。写代码最终是为解决问题服务的,不是为炫耀架构模式。
如果这篇文章对你有帮助,欢迎点赞关注。

2022

被折叠的 条评论
为什么被折叠?



