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

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());
    }
}

这个设计的核心变化是:控制流不再是一条直线,而是一个循环。事件循环不断接收各种事件,根据不同的事件类型做不同的事。好处也很明显:

  1. 流式输出天然支持:StreamChunk 事件来了就输出,不用等全部生成完
  2. 工具并发调用:多个工具调用可以同时发出,各自完成后通过 ToolResult 事件回报
  3. 扩展性好:新增一种事件类型,只需在 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)?
    }
}

四、实践中的坑和心得

说到底,架构选择没有绝对的对错。我作为一个半路出家的自学程序员,踩过的坑总结几点:

  1. 别过早优化:一开始就上事件驱动,代码复杂度翻倍,但你根本不知道瓶颈在哪。先用请求响应把功能跑通,跑通了再去 profile,看哪里慢。

  2. 事件驱动不等于高性能:如果你只有 1 个 LLM 调用和 1 个工具调用,而且它们是串行依赖的,事件驱动不会带来任何性能提升,反而增加了代码的理解成本。

  3. 状态的正确性是最大的挑战:事件驱动的代码最难的是管理状态。当 ToolResult 到达时,你怎么知道它对应的是哪次请求?我推荐给每个事件带上一个 request_id

  4. 多读读 Tokio 的源码:实际上 Tokio 的 scheduler 本身就是事件驱动的典范。理解了 tokio::select! 和 channel 的用法,事件驱动的 Agent 写起来会顺手很多。

五、总结

这篇文章从时序图出发,对比了请求响应和事件驱动两种 Agent 架构模式。我个人在实践中更倾向于混合架构——对外暴露简洁的请求响应接口,内部用事件驱动来协调 LLM 调用、工具执行和流式输出。

对于和我一样的自学转码选手,我的建议是:先理解透彻一个模式,再考虑要不要混用。不要因为"别人都说事件驱动好",就在项目初期引入过多复杂度。写代码最终是为解决问题服务的,不是为炫耀架构模式。

如果这篇文章对你有帮助,欢迎点赞关注。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值