Rust AI Agent 架构设计:事件驱动还是请求响应,先画个时序图

Rust AI Agent 架构设计:事件驱动还是请求响应,先画个时序图
Rust AI Agent 架构设计事件驱动还是请求响应先画个时序图今天想跟大家聊聊我在设计 Agent 架构时遇到的一个核心选择到底用事件驱动还是请求响应我先说结论两者不是互斥的而是要看场景搭配使用。下面我会用一个具体的例子从时序图开始一步步拆解我的思考过程。一、从最简单的请求响应开始一开始我写的 Agent 就是最朴素的请求响应模式。用户发一条消息Agent 调用大模型拿到回复结束。代码大概长这样/// Agent 的核心执行流程 —— 最简版本 /// 用户输入 - LLM调用 - 返回结果一条路走到黑 async fn run_agent_simple(user_input: str) - ResultString, 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: HashMapString, serde_json::Value, }, /// 工具执行完毕返回结果 ToolResult { tool_name: String, result: String, }, /// LLM 生成了一段文本流式 StreamChunk(String), /// LLM 返回了最终完整回答 FinalResponse(String), /// 发生了错误 Error(String), } /// 事件驱动的 Agent 核心 /// 用一个 channel 接收各类事件在循环中统一处理 struct EventDrivenAgent { /// 事件接收端 —— 各种异步任务把事件发到这里 event_rx: mpsc::UnboundedReceiverAgentEvent, /// 当前对话的上下文消息列表 messages: VecString, /// 待执行的工具调用队列 pending_tool_calls: Vec(String, HashMapString, 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)仍然返回一个Futureawait 之后就能拿到结果。这样上层代码不需要关心底层是请求响应还是事件驱动。内层使用事件驱动在 Agent 内部LLM 调用、工具执行、流式输出都通过事件来协调充分利用异步并发的优势。/// 混合架构的 Agent 对外接口 /// 对外表现为简单的请求响应内部使用事件驱动 impl Agent { /// 对调用者来说这就是一个简单的 async 函数 /// 但内部是完整的事件循环驱动 pub async fn chat(mut self, user_message: str) - ResultString, 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 调用、工具执行和流式输出。对于和我一样的自学转码选手我的建议是先理解透彻一个模式再考虑要不要混用。不要因为别人都说事件驱动好就在项目初期引入过多复杂度。写代码最终是为解决问题服务的不是为炫耀架构模式。如果这篇文章对你有帮助欢迎点赞关注。