1. 为什么我决定用 Rust 写一个 Agent 服务
先说结论:如果你打算把大模型的交互逻辑封装成一个长期运行、可观测、能扛住并发的小服务,Rust 加 SSE 加 SQLite 这套组合,是我目前试过最省心的方案之一。这篇内容就是围绕adk-rust这个上手过程,把我从零搭起一个 Agent 服务时踩过的坑、做过的取舍、以及最后跑通的完整链路,原原本本讲一遍。
Agent 这个词这两年已经被用烂了。有人管一个带工具调用的对话循环叫 Agent,有人管一套多角色编排系统叫 Agent,还有人把记忆、规划、执行全塞进去才肯叫 Agent。但落到工程上,你真正要解决的无非是几件事:怎么把用户输入接进来,怎么把大模型的流式输出实时吐出去,怎么在中间插入工具调用和状态管理,怎么把每一轮对话和中间状态持久化下来,以及怎么让这个服务在没人盯着的时候也能稳定跑着。这五件事,恰好就是adk-rust这类框架想帮你兜住的东西。
我选 Rust 不是因为想炫技。之前用别的语言写过类似的 Agent 服务,问题集中在两个地方:一是长时间运行的流式连接容易在内存和并发上出幺蛾子,二是打包部署的时候依赖一堆运行时,换个环境就翻车。Rust 编译出来是单个二进制,内存占用可控,异步运行时成熟,SSE 这种长连接场景处理起来心里有底。当然代价是学习曲线,尤其是所有权和异步生命周期这两块,第一次写会骂人。但服务一旦跑起来,那种"编译通过基本就能跑"的踏实感,是值得的。
adk-rust的定位,我理解是一个偏轻量的 Agent 开发套件,核心是把模型调用、工具注册、会话状态、流式输出这几块抽象出来,让你不用从零手搓 HTTP 客户端和事件循环。它跟那些重量级编排框架的区别在于,它不强迫你接受一整套世界观,你可以只用它的流式封装,也可以只用它的会话管理,剩下的自己接。这种"可拆可合"的设计,对上手阶段特别友好。
这篇文章适合谁看?如果你有基本的编程经验,对异步、HTTP、数据库这些概念不陌生,想动手写一个自己的 Agent 服务,那这篇能帮你少走弯路。如果你完全没碰过 Rust,也没关系,我会把关键概念用生活化的方式解释清楚,你照着抄作业也能跑起来。下面我按实际搭建顺序展开,从环境准备一路讲到流式渲染和持久化。
2. 环境准备:Rust 工具链与依赖库的那些细节
2.1 安装 Rust 与配置国内镜像源
Rust 的安装本身不复杂,官方推荐用 rustup 管理工具链。装完之后你会得到rustc(编译器)、cargo(包管理和构建工具)以及rustup(工具链管理器)。这里有个新手最容易忽略的点:默认的包源在国外,拉依赖的时候会慢到让你怀疑人生。所以第一件事是配置镜像源。
配置镜像源分两个层面。一个是 rustup 自身的工具链下载源,另一个是 cargo 拉取 crate(Rust 的包)的源。前者通过设置环境变量指向国内镜像,后者通过修改~/.cargo/config.toml文件来配置。我一般会建一个config.toml,把源替换成国内可用的镜像地址,这样cargo build的时候速度会快很多。
# ~/.cargo/config.toml [source.crates-io] replace-with = 'mirror' [source.mirror] registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"注意:镜像源地址会随时间变化,配置前最好确认一下当前可用的地址。如果某个源拉取失败,换一个再试,不要死磕。
配好之后,用cargo new agent-demo建一个项目,cargo run跑一下默认的 hello world,确认工具链没问题。这一步看着简单,但很多人卡在这里,因为镜像没配对,cargo build卡在 "Updating crates.io index" 半天不动。
2.2 编辑器与开发环境的选择
编辑器我用的是 VS Code,配合 rust-analyzer 插件。rust-analyzer 是 Rust 官方推荐的 LSP 实现,能提供补全、跳转、类型提示、内联错误这些功能。装好之后,第一次打开项目它会索引依赖,稍微等一会儿。索引完成后,写代码的体验会好很多,尤其是类型推断和错误提示,基本能帮你在编译前就发现大部分问题。
如果你习惯用别的编辑器,只要支持 LSP 都行。关键是 rust-analyzer 要跑起来,否则 Rust 这种强类型语言写起来会很痛苦。另外建议装一个crates插件,它能在Cargo.toml里显示每个依赖的最新版本,省得你手动去查。
2.3 核心依赖选型:axum、tokio、rusqlite
一个 Agent 服务的依赖大致分几类:HTTP 框架、异步运行时、数据库驱动、序列化、以及 SSE 相关的支持。我最终选的组合是 axum 做 HTTP 层,tokio 做异步运行时,rusqlite 或 sqlx 做 SQLite 访问,serde 做序列化。
为什么选 axum?它是 tokio 官方生态里的 Web 框架,跟 tokio 配合最自然,路由和中间件的写法也比较直观。相比 actix-web,axum 的抽象更薄,学习成本低一些,而且它的 extractor 机制处理请求参数很顺手。对于 Agent 服务这种需要处理流式响应和长连接场景,axum 对 SSE 的支持是原生的,直接返回一个Sse响应类型就行。
tokio 基本是 Rust 异步的事实标准,不用纠结。SQLite 这块,rusqlite 是同步的,sqlx 是异步的。如果你追求跟 tokio 一致的异步风格,用 sqlx;如果你想要更简单的 API 和更成熟的生态,rusqlite 配合连接池也够用。我一开始用 rusqlite,后来因为要在异步上下文里频繁访问,换成了 sqlx 的 SQLite 驱动,省去了手动包spawn_blocking的麻烦。
[dependencies] axum = "0.7" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } serde = { version = "1", features = ["derive"] } serde_json = "1" futures = "0.3"提示:依赖版本不要盲目追新,尤其是 axum 和 sqlx 这种迭代快的库,版本之间 API 差异可能很大。建议锁定一个你验证过能跑通的版本组合,再逐步升级。
3. 把大模型交互逻辑封装成可复用的服务层
3.1 Agent 服务的核心抽象:会话、消息、工具
在动手写代码之前,先把数据模型想清楚。一个 Agent 服务最核心的三个概念是会话(Session)、消息(Message)和工具(Tool)。会话代表一次完整的交互上下文,消息是会话里的每一条记录,工具是 Agent 可以调用的外部能力。
会话需要有一个唯一 ID,用来关联后续的所有消息。消息需要区分角色,至少要有用户、助手、工具三种。工具需要有一个名字、一段描述、以及一个可执行的函数。这些结构用 Rust 的 struct 和 enum 表达非常自然,配合 serde 的 derive,序列化成 JSON 也就是一行注解的事。
#[derive(Serialize, Deserialize, Clone)] struct Message { role: String, content: String, timestamp: i64, } #[derive(Serialize, Deserialize, Clone)] struct Session { id: String, messages: Vec<Message>, created_at: i64, }这里有个设计上的取舍:消息是全部放在内存里,还是每次都从数据库读?我的做法是内存里保留活跃会话的最近若干条消息,作为上下文窗口,完整历史落库。这样既保证了推理时的上下文完整,又不会让内存无限增长。上下文窗口的大小要根据模型的 token 限制来定,一般保留最近十几轮对话就够了。
3.2 模型调用的封装:从请求构造到流式解析
模型调用这块,核心是把一个 HTTP 请求发出去,然后把流式返回的数据一块块解析出来。大多数模型服务商的流式接口返回的是 SSE 格式,每一行以data:开头,后面跟一个 JSON 片段。你需要做的是逐行读取,遇到data: [DONE]就结束,否则解析 JSON 取出增量文本。
用 Rust 写这个逻辑,关键是处理字节流的分块。网络返回的数据不会正好按行切分,可能一行被拆成两个 chunk,也可能两个 chunk 拼成一行。所以你需要一个缓冲区,把收到的字节累积起来,按换行符切分,处理完整行,剩下的留在缓冲区等下一批数据。这个逻辑看着简单,但如果不处理好边界,就会出现 JSON 解析失败或者文本丢失的问题。
let mut buffer = String::new(); while let Some(chunk) = stream.next().await { let chunk = chunk?; buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim().to_string(); buffer = buffer[pos + 1..].to_string(); if line.starts_with("data: ") { let data = &line[6..]; if data == "[DONE]" { break; } // 解析 JSON,取出增量文本 } } }注意:
from_utf8_lossy会替换非法字节,如果模型返回的内容包含多字节字符被切断,可能出现乱码。更稳妥的做法是按字节缓冲,遇到完整行再转字符串。这个坑我在处理中文输出时踩过,表现为偶尔出现一个问号或者乱码字符。
3.3 工具调用的编排:让 Agent 真正能做事
光会聊天不算 Agent,能调用工具才算。工具调用的流程大致是:模型返回一个工具调用请求,包含工具名和参数,你的服务执行这个工具,把结果作为一条工具消息塞回上下文,再让模型基于结果继续生成。这个循环可能重复多次,直到模型不再请求工具,直接给出最终回答。
在 Rust 里实现这个循环,要注意的是工具执行可能是阻塞的,比如读写文件、查数据库。如果直接在异步上下文里做阻塞操作,会卡住整个运行时。正确做法是用tokio::task::spawn_blocking把阻塞操作丢到专门的线程池里执行。这个细节很多人会忽略,结果服务在并发稍微高一点的时候就卡顿。
工具注册我建议用一个 HashMap,键是工具名,值是一个返回Box<dyn Future>的闭包或者 trait 对象。这样新增工具只需要注册一下,不用改调用逻辑。工具的输入输出统一用 JSON,方便跟模型的 function calling 格式对接。
4. SSE 流式输出:让大模型的回答实时渲染出来
4.1 SSE 协议的本质与在 axum 中的实现
SSE 全称 Server-Sent Events,是一种基于 HTTP 的单向流式推送协议。它的本质很简单:服务端保持连接不关闭,持续往响应体里写数据,每条数据以data:开头,以两个换行结束。浏览器端的 EventSource API 会自动接收这些事件并触发回调。相比 WebSocket,SSE 的优势是协议简单、自带重连、走标准 HTTP 端口,缺点是只能服务端推客户端,不能反向。
在 axum 里实现 SSE,直接用axum::response::sse::Sse类型,配合一个Stream。你可以用tokio_stream或者futures里的 channel 来构造这个流。我的做法是创建一个 mpsc channel,把模型返回的增量文本通过 sender 发进去,receiver 转成 SSE 流返回给客户端。这样模型调用和 HTTP 响应就解耦了,模型那边慢慢生成,客户端这边实时收到。
use axum::response::sse::{Event, Sse}; use tokio_stream::wrappers::ReceiverStream; let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(100); tokio::spawn(async move { // 调用模型,逐块发送 tx.send(Ok(Event::default().data("你好"))).await.unwrap(); }); Sse::new(ReceiverStream::new(rx))4.2 流式渲染中的中断处理与超时控制
流式输出最怕两件事:一是客户端断开连接后服务端还在傻傻地生成,浪费资源;二是模型那边卡住不返回,连接一直挂着。前者需要监听连接关闭事件,一旦客户端断开就取消模型调用。在 axum 里可以通过检查 sender 的send是否返回错误来判断,如果 receiver 被 drop 了,send 会失败,这时候就可以中止任务。
后者需要设置超时。SSE 连接如果长时间没有数据,中间的网络设备可能会主动断开,客户端也会报 "idle timeout waiting for sse" 这类错误。解决办法是定期发送心跳,也就是一个空的注释行: keep-alive,保持连接活跃。同时给模型调用本身设置一个总超时,超过就返回错误并关闭流。
let timeout = tokio::time::timeout(Duration::from_secs(60), model_call()); match timeout.await { Ok(result) => { /* 正常处理 */ } Err(_) => { /* 超时,发送错误事件并结束 */ } }提示:心跳间隔一般设 15 到 30 秒比较合适。太频繁浪费带宽,太稀疏起不到保活作用。另外记得在客户端也配置重连逻辑,网络抖动导致的断开能自动恢复。
4.3 前端如何消费 SSE 流并做增量渲染
前端消费 SSE 有两种方式:用原生 EventSource,或者用 fetch 加 ReadableStream。EventSource 的优点是简单,自动重连,缺点是只能发 GET 请求,不能自定义 header,传复杂参数不方便。所以我更推荐用 fetch 的方式,手动读取流并解析。
用 fetch 消费 SSE 的核心是拿到response.body的 reader,然后循环读取,把字节解码成文本,按行解析。每收到一个data:行就更新界面。这里要注意的是,React 这类框架里更新状态会触发重渲染,如果每个字符都 setState,性能会很差。正确的做法是累积一小段再更新,或者用 ref 直接操作 DOM 文本节点。
const response = await fetch('/api/chat', { method: 'POST', body: ... }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按行解析,更新界面 }增量渲染的体验关键在于"打字机效果"要自然。如果模型返回很快,一次性刷一大段,反而没有流式的感觉。可以在前端加一个小的缓冲队列,按固定速率吐字,视觉上更舒服。这个纯属体验优化,不影响功能。
5. SQLite 持久化:会话历史与状态存储的落地
5.1 表结构设计与索引考量
SQLite 做 Agent 服务的存储,够用且省事。核心表就两张:会话表和消息表。会话表存会话 ID、创建时间、标题这些元信息;消息表存消息 ID、会话 ID、角色、内容、时间戳。消息表上建一个会话 ID 的索引,查询某个会话的历史时就能走索引,速度快很多。
CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT, created_at INTEGER NOT NULL ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ); CREATE INDEX idx_messages_session ON messages(session_id, created_at);这里有个经验:内容字段用 TEXT 存 JSON 字符串,比拆成一堆列灵活。因为不同角色的消息结构可能不一样,工具调用消息还带参数和结果,用 JSON 存省得频繁改表结构。查询的时候在应用层解析就行。
5.2 用 sqlx 做异步数据库访问
sqlx 的好处是编译期就能检查 SQL 语句的合法性,前提是你连着一个真实的数据库做编译。这个特性叫 compile-time verification,用起来很爽,但初次配置有点麻烦,需要设置DATABASE_URL环境变量指向一个已经建好表的数据库文件。如果嫌麻烦,也可以用运行时的query方法,牺牲一点安全性换便利。
连接池的配置要注意 SQLite 的并发特性。SQLite 默认是单写多读,写操作会加锁。如果你的服务写频繁,建议开启 WAL 模式,能显著提升并发读写性能。WAL 模式下读和写可以同时进行,不会互相阻塞。
let pool = SqlitePoolOptions::new() .max_connections(5) .connect("sqlite:agent.db?mode=rwc").await?; sqlx::query("PRAGMA journal_mode=WAL;").execute(&pool).await?;注意:SQLite 文件默认没有加密,如果存的是敏感对话内容,要考虑加密方案。SQLite 本身有加密扩展,但需要额外编译。简单点的做法是在应用层加密内容字段再存。
5.3 会话恢复与上下文重建
服务重启后,用户回来还想接着之前的对话,这就需要从数据库恢复会话。恢复的逻辑是:根据会话 ID 查出最近 N 条消息,按时间排序,重新构造成模型需要的上下文格式。这里要注意消息的顺序和角色映射,工具消息在恢复时要正确还原成模型能理解的格式。
上下文重建有个坑:如果历史消息太多,超过模型的 token 限制,需要做截断。截断不能简单地从头砍,因为系统提示词和最近的对话最重要。我的做法是保留系统提示词,然后从最近的消息往前取,直到接近 token 上限。token 数可以用简单的字符数估算,中文大概一个字一个 token,英文四个字符一个 token,粗略够用。
6. 上手过程中踩过的坑与排查思路
6.1 流式连接中途断开的排查链路
我遇到过一次线上问题:用户反馈回答到一半就停了,前端报 "stream disconnected before completion"。排查过程是这样的:先看服务端日志,发现模型调用正常返回了,但 SSE 流提前关闭。接着检查网络层,发现是中间有个反向代理设置了 60 秒的超时,而模型生成慢的时候超过了这个时间。
定位到原因后,解决办法有两个:一是加心跳,让连接在代理看来一直是活跃的;二是调大代理的超时时间。我两个都做了,心跳设 20 秒一次,代理超时调到 300 秒。改完之后再没出现过中途断开。
这个排查链路的关键是分段定位:先确认是模型的问题还是传输的问题,再确认是服务端主动关闭还是被动断开。日志要打全,尤其是连接建立和关闭的时间点,以及每次发送数据的时间戳,这样一看时间间隔就能判断卡在哪。
6.2 异步阻塞导致的性能骤降
另一个坑是数据库操作阻塞了异步运行时。一开始我用 rusqlite 直接在 async 函数里查数据库,本地测试没问题,一上并发就发现响应变慢。原因是 rusqlite 是同步的,查询时会阻塞当前线程,而 tokio 的工作线程数量有限,几个慢查询就能把线程占满。
解决办法是换成 sqlx 的异步驱动,或者用spawn_blocking把同步操作包起来。我选了前者,因为代码更干净。如果你坚持用 rusqlite,记住所有数据库操作都要包在spawn_blocking里,否则迟早出问题。
6.3 中文乱码与字节边界处理
前面提过 UTF-8 边界的问题,这里展开说。SSE 流是按字节传输的,一个中文字符占三个字节,如果这三个字节被拆到两个 chunk 里,直接转字符串就会乱码。正确的做法是维护一个字节缓冲区,只在遇到换行符时才尝试转字符串,因为换行符是单字节的,不会跟多字节字符混淆。
let mut byte_buffer: Vec<u8> = Vec::new(); // 收到 chunk 后 byte_buffer.extend_from_slice(&chunk); while let Some(pos) = byte_buffer.iter().position(|&b| b == b'\n') { let line_bytes = byte_buffer.drain(..=pos).collect::<Vec<u8>>(); let line = String::from_utf8_lossy(&line_bytes); // 处理这一行 }这个处理方式能保证只在完整行上做字符串转换,避免多字节字符被切断。实测下来,中文输出再没出现过乱码。
7. 一些让服务更稳的工程化建议
7.1 日志与可观测性
Agent 服务的调试比普通服务难,因为涉及模型这个黑盒。我的建议是把每次模型调用的请求和响应都记下来,包括耗时、token 数、是否触发工具调用。这些日志在排查问题时非常有用。日志级别用 info 记关键节点,debug 记详细内容,生产环境别开 debug,不然日志量爆炸。
结构化日志比纯文本好,用 JSON 格式输出,方便后续用工具分析。Rust 里可以用 tracing 配合 tracing-subscriber,输出带字段的结构化日志。每个请求带一个 trace id,贯穿整个处理链路,排查时按 id 一搜就能看到全貌。
7.2 配置管理与环境隔离
模型 API 的地址、密钥、超时时间这些配置,不要硬编码在代码里。用环境变量或者配置文件管理,不同环境用不同的配置。Rust 里可以用config这个 crate,支持从文件和环境变量合并读取。密钥这类敏感信息只放环境变量,不进代码仓库。
开发、测试、生产三套环境的数据库也要隔离。开发用本地文件,生产用独立的数据库文件或者挂载的存储卷。SQLite 文件要定期备份,直接复制文件就行,但记得在 WAL 模式下要同时复制-wal和-shm文件,或者先做 checkpoint。
7.3 部署与进程管理
Rust 编译出来的二进制可以直接扔到服务器上跑,不需要装运行时。用 systemd 或者 supervisor 做进程管理,配置自动重启。如果要用容器,基础镜像可以用 debian-slim 或者 alpine,把二进制拷进去就行,镜像体积能控制在几十兆。
部署的时候注意数据库文件的路径要挂载出来,不然容器重启数据就没了。日志输出到标准输出,由进程管理器收集。健康检查接口加一个,返回服务状态和数据库连接状态,方便监控。
8. 关于 Agent 服务后续扩展的几点想法
跑通基础版本之后,我陆续加了一些东西。一个是多会话管理,用户可以同时开多个对话,互不干扰。这个在数据模型上已经支持了,前端加个会话列表就行。另一个是工具的动态注册,把工具定义放到配置文件里,启动时加载,新增工具不用重新编译。
记忆这块我还在摸索。简单的做法是把历史消息全部带上,但 token 消耗大。进阶一点的做法是做摘要,把久远的对话压缩成一段总结。再复杂的就是向量检索,把历史消息做 embedding,按相关性召回。这个工程量不小,我打算后面单独做。
还有一个方向是流式输出的多路复用。现在是一个请求一个流,如果用户想同时看多个模型的回答做对比,就需要把多个流合并到一个 SSE 连接里。技术上可以用事件类型区分不同来源,前端按类型分发。这个场景在一些对比评测的需求里挺常见。
最后说个实际的体会:Agent 服务的复杂度不在于单个功能,而在于这些功能怎么组合。流式、工具、记忆、持久化,每一个单独看都不难,但要让它们协同工作,还要保证稳定和性能,就需要在架构上提前想清楚。Rust 的类型系统和所有权模型在这种场景下反而是优势,它逼着你在编译期就把资源的生命周期理清楚,很多并发和内存问题在写代码阶段就被拦住了。我踩过的那些坑,大部分都不是 Rust 本身的坑,而是对异步和流式处理理解不到位导致的。把这些理顺之后,剩下的就是体力活了。