AI Agent 的流式架构:从输入到输出的全链路数据流设计
我做第一个 AI Agent 项目时,把所有逻辑都塞在一个函数里——输入→处理→输出,串行执行。后来加了"流式输出"需求,整个架构要大改,改了三天才勉强能跑。那时候才意识到:Agent 的数据流设计应该在第一天就做好,而不是后期硬塞。这篇就把我从"串行杂乱"到"流式清晰"的架构演进过程写出来。
一、为什么 Agent 需要"流式"?——从用户体验到技术实现
AI Agent 的"流式"有两层含义:
- 用户体验层:用户不想等 10 秒看到完整回答,而是希望每个 token 逐步出现在屏幕上——像 ChatGPT 那样"边思考边输出"。
- 技术层:Agent 的处理链路很长(输入→意图识别→工具选择→工具执行→结果整合→输出),中间每个环节都可能产生可展示的中间结果。如果等所有环节都完成才输出,用户体验极差。
// ❌ 串行处理:所有步骤完成后才返回结果
async fn serial_agent(user_input: &str) -> String {
// 第一步:意图识别(耗时 500ms)
let intent = recognize_intent(user_input).await;
// 第二步:选择工具(耗时 200ms)
let tool = select_tool(&intent).await;
// 第三步:执行工具(耗时 2-5秒)
let tool_result = execute_tool(&tool).await;
// 第四步:整合结果(耗时 300ms)
let final_output = synthesize_result(&tool_result).await;
// 用户等了 3-6秒才看到结果!
final_output
}
sequenceDiagram
participant U as 用户
participant A as Agent
U->>A: "帮我查 Rust 最新版本"
Note over A: 意图识别 500ms
Note over A: 工具选择 200ms
Note over A: 执行工具 3000ms
Note over A: 整合结果 300ms
A-->>U: 完整回答(等待4秒)
Note over U,U: 用户等了4秒才看到任何输出 ❌
style A fill:#f96
二、流式架构的核心:Channel + Stream + 管道模式
流式架构的关键是:把每个处理环节变成独立的"管道节点",节点之间用 channel 连接,数据像流水一样从输入端逐步流向输出端。每个节点处理完一部分数据,立刻通过 channel 发给下游,同时也可以发给"展示层"——用户就能看到中间结果。
use tokio::sync::mpsc;
use tokio_stream::{StreamExt, wrappers::ReceiverStream};
/// Agent 事件类型:每个处理环节都可能产生可展示的事件
#[derive(Debug, Clone)]
enum AgentEvent {
// 意图识别完成
IntentRecognized { intent: String, confidence: f32 },
// 工具选择完成
ToolSelected { tool_name: String, reason: String },
// 工具执行中间结果
ToolProgress { step: String, partial_result: String },
// 工具执行完成
ToolCompleted { result: String },
// 最终输出(逐token)
OutputToken { token: String },
// 流式输出完成
Done,
}
/// 流式 Agent 架构:每个环节通过 channel 向下游传递事件
struct StreamingAgent {
// 输入端:接收用户消息
input_rx: mpsc::Receiver<String>,
// 输出端:发送 Agent 事件给展示层
event_tx: mpsc::Sender<AgentEvent>,
}
impl StreamingAgent {
/// 创建流式 Agent
fn new(input_rx: mpsc::Receiver<String>, event_tx: mpsc::Sender<AgentEvent>) -> Self {
Self { input_rx, event_tx }
}
/// 启动流式处理:每个环节独立运行,通过 channel 串联
async fn run(&mut self) {
while let Some(user_input) = self.input_rx.recv().await {
// 为每个用户输入创建一条完整的处理管道
self.process_stream(user_input).await;
}
}
/// 单条输入的流式处理管道
async fn process_stream(&self, user_input: String) {
// 管道1:意图识别 → 工具选择
let (intent_tx, intent_rx) = mpsc::channel(10);
// 管道2:工具选择 → 工具执行
let (tool_tx, tool_rx) = mpsc::channel(10);
// 管道3:工具执行 → 结果整合 → 输出
let (result_tx, result_rx) = mpsc::channel(10);
let event_tx = self.event_tx.clone();
// 每个管道节点独立 spawn,数据流式传递
let intent_stage = tokio::spawn(intent_recognizer(user_input, intent_tx, event_tx.clone()));
let tool_stage = tokio::spawn(tool_selector(intent_rx, tool_tx, event_tx.clone()));
let execute_stage = tokio::spawn(tool_executor(tool_rx, result_tx, event_tx.clone()));
let output_stage = tokio::spawn(output_synthesizer(result_rx, event_tx));
// 等待所有管道完成
let _ = tokio::join!(intent_stage, tool_stage, execute_stage, output_stage);
}
}
/// 管道节点1:意图识别(完成后向下游和展示层发送事件)
async fn intent_recognizer(
input: String,
intent_tx: mpsc::Sender<String>,
event_tx: mpsc::Sender<AgentEvent>,
) {
// 识别意图
let intent = recognize_intent(&input).await;
// 立刻通知展示层:用户可以看到"我理解了你的意图"
event_tx.send(AgentEvent::IntentRecognized {
intent: intent.clone(),
confidence: 0.92,
}).await.unwrap();
// 把意图传给下游
intent_tx.send(intent).await.unwrap();
}
/// 管道节点2:工具选择
async fn tool_selector(
mut intent_rx: mpsc::Receiver<String>,
tool_tx: mpsc::Sender<String>,
event_tx: mpsc::Sender<AgentEvent>,
) {
let intent = intent_rx.recv().await.unwrap();
let tool = select_tool(&intent).await;
// 通知展示层:用户可以看到"我选择了XX工具"
event_tx.send(AgentEvent::ToolSelected {
tool_name: tool.clone(),
reason: format!("基于意图 '{}' 选择", intent),
}).await.unwrap();
tool_tx.send(tool).await.unwrap();
}
/// 管道节点3:工具执行(可能产生多个中间结果)
async fn tool_executor(
mut tool_rx: mpsc::Receiver<String>,
result_tx: mpsc::Sender<String>,
event_tx: mpsc::Sender<AgentEvent>,
) {
let tool = tool_rx.recv().await.unwrap();
let result = execute_tool_streaming(&tool, &event_tx).await;
result_tx.send(result).await.unwrap();
}
/// 管道节点4:结果整合 → 流式输出
async fn output_synthesizer(
mut result_rx: mpsc::Receiver<String>,
event_tx: mpsc::Sender<AgentEvent>,
) {
let result = result_rx.recv().await.unwrap();
// 逐 token 输出(模拟 LLM 的流式生成)
for token in result.split_whitespace() {
event_tx.send(AgentEvent::OutputToken {
token: token.to_string(),
}).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await; // 模拟生成延迟
}
event_tx.send(AgentEvent::Done).await.unwrap();
}
flowchart LR
A["用户输入"] --> B["意图识别<br/>500ms"]
B --> C["工具选择<br/>200ms"]
C --> D["工具执行<br/>2-5s"]
D --> E["结果整合<br/>300ms"]
B -->|IntentRecognized<br/>立刻展示| S["展示层<br/>用户屏幕"]
C -->|ToolSelected<br/>立刻展示| S
D -->|ToolProgress<br/>中间结果| S
E -->|OutputToken<br/>逐词输出| S
style S fill:#6f6
三、流式输出到客户端:Server-Sent Events 的实战对接
前端展示流式结果,最常用的方案是 SSE(Server-Sent Events)。后端把 AgentEvent 转成 SSE 格式,前端用 EventSource API 接收。
use axum::{
response::sse::{Event, Sse},
routing::get,
Router,
};
use futures::stream::Stream;
use std::convert::Infallible;
/// 把 AgentEvent 转成 SSE 格式
fn event_to_sse(event: AgentEvent) -> Event {
let data = match event {
AgentEvent::IntentRecognized { intent, confidence } => {
format!("type=intent&intent={}&confidence={}", intent, confidence)
}
AgentEvent::ToolSelected { tool_name, reason } => {
format!("type=tool&name={}&reason={}", tool_name, reason)
}
AgentEvent::ToolProgress { step, partial_result } => {
format!("type=progress&step={}&result={}", step, partial_result)
}
AgentEvent::ToolCompleted { result } => {
format!("type=completed&result={}", result)
}
AgentEvent::OutputToken { token } => {
format!("type=token&content={}", token)
}
AgentEvent::Done => {
format!("type=done")
}
};
Event::default().data(data)
}
/// SSE handler:将 Agent 的流式事件推送到客户端
async fn sse_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let (event_tx, event_rx) = mpsc::channel(100);
// 启动 Agent 流式处理
let (input_tx, input_rx) = mpsc::channel(10);
let mut agent = StreamingAgent::new(input_rx, event_tx);
tokio::spawn(async move { agent.run().await });
// 发送测试输入
input_tx.send("帮我查 Rust 最新版本".to_string()).await.unwrap();
// 将 event_rx 转为 SSE Stream
let stream = ReceiverStream::new(event_rx).map(event_to_sse).map(Ok);
Sse::new(stream)
}
/// 路由配置
fn app() -> Router {
Router::new().route("/api/chat/stream", get(sse_handler))
}
前端接收 SSE 的代码(JavaScript,不是 Rust):
// 前端接收 SSE 流式事件
const eventSource = new EventSource('/api/chat/stream');
eventSource.onmessage = (event) => {
const data = new URLSearchParams(event.data);
switch (data.get('type')) {
case 'intent':
// 显示:Agent 识别了你的意图
appendMessage(`思考中:识别意图 "${data.get('intent')}"`);
break;
case 'tool':
// 显示:Agent 选择了工具
appendMessage(`使用工具:${data.get('name')}`);
break;
case 'progress':
// 显示:工具执行进度
appendMessage(`执行中:${data.get('step')}`);
break;
case 'token':
// 显示:逐词输出结果
appendToken(data.get('content')); // 关键:逐词追加!
break;
case 'done':
// 流式输出完成
eventSource.close();
break;
}
};
sequenceDiagram
participant C as 客户端
participant S as Axum SSE
participant A as 流式 Agent
C->>S: GET /api/chat/stream
S->>A: 启动流式管道
A-->>S: IntentRecognized (500ms后)
S-->>C: SSE: type=intent
Note over C: "思考中:识别意图"
A-->>S: ToolSelected (700ms后)
S-->>C: SSE: type=tool
Note over C: "使用工具:search_api"
A-->>S: OutputToken (逐词)
S-->>C: SSE: type=token&content=Rust
S-->>C: SSE: type=token&content=1.75
Note over C: 逐词显示 ✅
A-->>S: Done
S-->>C: SSE: type=done
style C fill:#6f6
四、多工具并行:Agent 同时调用多个工具的流式设计
很多 Agent 场景需要"并行调用多个工具"——比如用户问"Rust 和 Go 的最新版本分别是什么",Agent 需要同时查两个 API。串行执行太慢(4秒+4秒=8秒),并行执行只需要4秒,但流式展示更复杂。
use tokio::sync::mpsc;
use futures::future::join_all;
/// 并行工具执行:多个工具同时运行,结果流式汇总
async fn parallel_tool_execution(
tools: Vec<String>,
event_tx: mpsc::Sender<AgentEvent>,
) -> Vec<String> {
// 为每个工具创建独立的执行任务
let tasks: Vec<_> = tools.iter().map(|tool| {
let event_tx = event_tx.clone();
tokio::spawn(async move {
// 通知展示层:开始执行工具
event_tx.send(AgentEvent::ToolProgress {
step: format!("开始执行 {}", tool),
partial_result: String::new(),
}).await.ok();
// 执行工具(每个工具独立运行)
let result = execute_tool(tool).await;
// 通知展示层:工具完成
event_tx.send(AgentEvent::ToolCompleted {
result: result.clone(),
}).await.ok();
result
})
}).collect();
// 等待所有工具并行完成
let results: Vec<String> = join_all(tasks)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
results // 返回所有工具的结果
}
/// 使用示例:Agent 同时查询 Rust 和 Go 版本
async fn multi_tool_agent(user_input: &str, event_tx: mpsc::Sender<AgentEvent>) {
let intent = recognize_intent(user_input).await;
// 根据意图选择多个工具
let tools = vec![
"search_rust_version".to_string(),
"search_go_version".to_string(),
];
// 并行执行所有工具
let results = parallel_tool_execution(tools, event_tx.clone()).await;
// 整合多个工具的结果
let output = synthesize_multi_results(&results).await;
// 流式输出最终答案
for token in output.split_whitespace() {
event_tx.send(AgentEvent::OutputToken {
token: token.to_string(),
}).await.ok();
}
event_tx.send(AgentEvent::Done).await.ok();
}
async fn synthesize_multi_results(results: &[String]) -> String {
format!("查询结果:{}", results.join(";"))
}
flowchart TD
A["用户输入<br/>查 Rust 和 Go 版本"] --> B["意图识别"]
B --> C["选择工具1: search_rust"]
B --> D["选择工具2: search_go"]
C --> E["并行执行<br/>2秒"]
D --> E
E --> F["结果1: Rust 1.75"]
E --> G["结果2: Go 1.22"]
F --> H["整合结果"]
G --> H
H --> I["流式输出<br/>逐词展示"]
C -->|ToolProgress<br/>开始执行| S["展示层"]
D -->|ToolProgress<br/>开始执行| S
F -->|ToolCompleted<br/>Rust结果| S
G -->|ToolCompleted<br/>Go结果| S
style E fill:#6cf
style S fill:#6f6
我在多工具并行时踩过一个 SSE 顺序的坑:工具 A 先完成、工具 B 后完成,但因为 tokio spawn 的调度顺序,ToolCompleted 事件 B 先到达 SSE handler。前端按到达顺序展示,用户看到"Go结果→Rust结果",和实际执行顺序相反。后来给每个事件加了 sequence number,前端按 seq 排序。
并行工具调用还需要关注取消传播:如果主请求被用户取消,所有正在进行的子工具调用也应及时中断,否则浪费 token。用一个 CancellationToken 在所有子任务间传播取消信号,可以在 Ctrl+C 时干净地停掉所有下游 IO。
五、总结
Agent 的流式架构核心是"管道模式":每个处理环节是独立的管道节点,节点之间用 channel 连接,数据逐步流动,中间结果随时展示。具体做法:
- AgentEvent 枚举:定义所有可能的中间事件类型,每个管道节点处理完就发送事件。
- channel + tokio::spawn:每个节点独立运行,用 channel 串联。channel 的容量就是背压控制点。
- SSE 对接:把 AgentEvent 转成 SSE 格式推送到前端,前端用 EventSource 接收并逐步展示。
- 并行工具执行:多个工具用
join_all并行运行,结果流式汇总,比串行快 N 倍。
作为的 Rust 学习者,流式架构让我真正理解了"异步编程的价值"——不是为了"快",而是为了"流"。串行架构追求的是"最终结果快",流式架构追求的是"中间过程可见"。对于 Agent 这种多步骤场景,"过程可见"比"结果快"更重要——用户等 4 秒看到完整回答和逐步看到思考过程,体验完全不同。
我的教训是:一开始就设计流式架构,不要后期硬加。"串行→流式"的改造比"一开始就流式"的设计成本高很多——因为 channel 和事件类型要渗透到每个环节,改起来是全局性的。先画管道图(Mermaid),再写代码,效率会高很多。


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



