2026/11/3 17:00:39

Rust 实现高并发 AI Agent 服务:adk-rust 框架实战与避坑指南

Rust 实现高并发 AI Agent 服务:adk-rust 框架实战与避坑指南 手头有个项目要用 Rust 写 Agent 服务调研了一圈最后在 adk-rust 上落地了。这篇文章就是整个上手过程的记录包括框架选型的思路、环境搭建、核心代码怎么组织、跑通一个完整请求要经过哪些环节以及我实际踩过的几个坑。如果你也在考虑用 Rust 写 Agent或者已经在用 adk-rust 但有些细节没摸透这篇应该能省你不少时间。先交代一下背景。AI Agent 服务现在的主流实现语言基本被 Python 垄断生态成熟、轮子多但落地到生产环境时高并发、资源占用、部署体积这些问题就慢慢浮出水面。Rust 在性能、内存安全和静态类型上的优势恰好是挤压 Python 方案空间的那只手。adk-rust 是 Google ADKAgent Development Kit的 Rust 实现核心是提供一套异步优先的 Agent 运行时让你用 Rust 定义 Agent、挂工具、接模型、管理会话然后在 tokio 或者任何异步运行时里跑起来。1. 为什么在 Agent 服务里选 Rust整体设计思路1.1 不是所有 Agent 服务都需要 Python先说一个我自己的判断标准什么时候该用 Rust 写 Agent什么时候老老实实用 Python。如果你的 Agent 服务是内部工具、并发量不高、调用频率低Python 完全够用LangChain、LlamaIndex 或者官方 ADK Python 版都能满足需求。但一旦你的 Agent 服务要面向外部用户、要支撑大量并发会话、要常驻内存跑 24 小时Python 的问题就变得非常具体GIL 限制多线程扩展、内存占用随会话数线性增长、部署镜像体积动不动几百兆。Rust 的优势不是“在 Python 也能干的事上干得更好”而是它改变了一些硬约束。同样是支撑一万个并发会话Python 方案可能需要横向扩容三四个实例Rust 方案单实例就能扛住而且内存占用可能只有前者的零头。这不是挤牙膏式的优化是量级上的差别。1.2 adk-rust 的定位一个能用的 Agent 运行时先说清楚adk-rust 不是什么天马行空的新框架它的设计思路和 Google ADK Python 版是一脉相承的定义 Agent给 Agent 挂工具Agent 通过 LLM 决定调什么工具、怎么编排最后返回结果。但 Rust 版有它自己的特点异步优先整个运行时的核心是 async trait 和 tokio所有 IO 操作都是异步的天然适合高并发场景。类型安全Agent 的状态、工具参数、消息类型都有明确的 Rust 类型约束。编不过就运行不了这句调侃在 Agent 开发里反而变成了一种保护。轻量部署编译产物是单个二进制扔到容器里就能跑不需要装 Python 运行时。用生活类比来说Python 版 ADK 像是一辆配置齐全的房车什么都有但油耗大、体积大adk-rust 像一辆改装过的越野车配置没那么豪华但该有的都有而且能跑长途、能拖重货。1.3 选型时我考量的几个关键点我在定方案前列了一个清单逐项对比考量维度Python 方案adk-rust开发效率高生态丰富中等需要写类型定义运行性能中等受 GIL 限制高接近零开销抽象内存占用高会话状态吃内存低结构化管理状态部署体积大需要解释器小单二进制文件并发能力需要多进程/多实例单实例高并发异步原生类型安全弱运行时才能发现问题强编译期拦截错误我的结论是如果这个 Agent 服务要做成真正面向用户的生产级服务投入产出比最高的路线就是 Rust。开发效率上确实会慢一些但这种“慢”换来的是上线后的省心。2. 环境准备与项目初始化2.1 工具链版本说明这一步没什么玄学就是要把 Rust 工具链准备好。我在 Ubuntu 22.04 上实测Rust 版本用 stable 分支就行当前我用的是 1.85 以上的版本。如果你用的是较老的 stable建议先rustup update确保不会碰到 trait 实现上的兼容性问题。adk-rust 对异步运行时要求是基于 tokio 的所以tokio必须带着rt-multi-thread和macros特性一起引入。另外serde是必须的因为 Agent 的消息传递、工具调用参数、模型请求响应都要做序列化和反序列化。2.2 创建项目并配置依赖初始化项目这一步不多说直接看操作cargo new adk-rust-demo cd adk-rust-demo编辑Cargo.toml加入以下依赖[dependencies] adk { version 0.1, features [full] } tokio { version 1, features [rt-multi-thread, macros] } serde { version 1, features [derive] } serde_json 1 anyhow 1 tracing 0.1 tracing-subscriber 0.3这里说明一下为什么带fullfeature。adk-rust 的默认特性只包含最基础的运行时full会把 LLM 客户端、工具调用、会话管理等模块全部启用。如果你只想用最核心的部分可以不启用full按需逐个开特性但作为上手实践直接全量引入最省心。2.3 验证环境是否可用依赖配置好之后先写一个最小程序验证环境没问题use adk::prelude::*; #[tokio::main] async fn main() - Result() { tracing_subscriber::fmt().init(); tracing::info!(adk-rust environment check passed); Ok(()) }运行cargo run如果能看到日志输出说明环境和依赖都正常。这里有个小细节adk 的 prelude 模块里导出了 Agent trait、消息类型、LLM 客户端的类型定义等常用项不需要逐个use。3. 核心实现从单 Agent 到可扩展服务3.1 定义自己的 Agentadk-rust 里Agent 的核心是一个异步 trait。你需要实现AsyncAgent这个 trait 要求你定义一个handle_message方法接收消息上下文返回AgentResponse。看一个最简单的示例use adk::prelude::*; use async_trait::async_trait; #[derive(Debug, Clone)] struct MyAgent { name: String, } #[async_trait] impl AsyncAgent for MyAgent { type State (); async fn handle_message( self, context: AgentContext, messages: [Message], ) - ResultAgentResponse { // 这里就是 Agent 的核心逻辑 let last_message messages.last().map(|m| m.content.clone()).unwrap_or_default(); Ok(AgentResponse { content: format!(收到消息内容是{}, last_message), ..Default::default() }) } }这个示例看起来很朴素但它透露了一个重要信息adk-rust 的 Agent 是一个纯异步的类型你可以在handle_message里做任何异步操作——调用 LLM、查数据库、请求外部 API——只要最终返回一个AgentResponse。在实际项目里State 是你自己定义的类型。adk-rust 允许你给 Agent 挂一个会话状态比如对话历史、用户偏好、临时上下文的累计这些都可以按需塞进 State。不过需要提醒一下State 的粒度设计直接影响并发安全性后文会专门讲。3.2 接入 LLM客户端是怎么工作的光有 Agent 外壳还不够真正干活的还是 LLM。adk-rust 里LLM 客户端是通过一个抽象的LLMClienttrait 接入的。它把“语言模型”抽象成一个对象输入消息列表输出文本内容。我用的是 OpenAI 兼容接口的客户端因为大部分模型服务商都提供兼容接口包括我自己内部部署的模型网关。直接看配置代码use adk::llm::{OpenAIChatCompletionsClient, LLMClient}; let llm_client OpenAIChatCompletionsClient::builder() .api_key(std::env::var(LLM_API_KEY)?) .base_url(std::env::var(LLM_BASE_URL)?) .model(qwen2.5-72b-instruct) .temperature(0.7) .build()?;这里要强调一个容易被忽略的细节base_url必须填你实际可访问的地址。如果你用的是官方 OpenAI 接口这步就填官方地址如果你像我一样用的是内网模型网关就填网关地址。adk-rust 不会帮你做任何代理或转发它只负责把请求发到对应地址。还有个经验之谈temperature参数建议放在配置层而不是 Agent 代码层。因为不同场景可能需要不同的采样温度比如写代码的 Agent 适合低温做头脑风暴的 Agent 适合高温。把温度做成可配置项后面调优就不用改代码重新编译了。3.3 把 LLM 挂到 Agent 上现在我们把 LLM 客户端和 Agent 结合起来。adk-rust 里这种事情是怎么做的呢答案是通过上下文构建器。看完整代码use adk::runtime::{AgentRuntime, DefaultAgentContext}; let agent MyAgent { name: demo.to_string() }; let context DefaultAgentContext::builder() .llm_client(llm_client) .build()?; let runtime AgentRuntime::builder() .agent(agent) .context(context) .build()?;这里AgentRuntime是整个流程的编排者它接收用户输入把消息交给 AgentAgent 在handle_message里通过 context 访问 LLM 客户端获取模型回复再返回结果。我会把这三层关系用一句话总结Agent定义行为Context提供能力Runtime负责执行。3.4 状态管理的类型设计这是容易翻车的地方。adk-rust 的 Agent trait 关联了一个State类型如果你在设计状态时用了可变引用或不安全的内部结构后续在并发场景下会遇到各种借用和生命周期报错。我推荐的做法状态类型全部用Clone Send Sync约束下的不可变数据结构需要修改时用Arc和内部可变性。use std::sync::Arc; use tokio::sync::RwLock; #[derive(Debug, Clone)] struct SessionState { conversation: ArcRwLockVecMessage, user_profile: OptionString, }为什么这么做因为 Agent 可能在运行时同时处理多个会话。如果你把状态写死在 Agent 结构体里多个会话之间会互相污染如果你在handle_message里临时创建状态Agent 的上下文连续性和记忆能力就丢了。用ArcRwLockT包一层既能在多个异步任务之间安全共享又能保证读多写少的场景下性能可接受。4. 实操过程跑通一个完整示例4.1 完整代码实战定义一个带记忆的 Agent现在我们把前文的代码整合起来写一个能记住对话内容的 Agent。这是在实际项目中非常常见的需求Agent 需要知道“之前聊了什么”才能给出连贯的回答。use adk::prelude::*; use adk::llm::{OpenAIChatCompletionsClient, LLMClient}; use std::sync::Arc; use tokio::sync::RwLock; #[derive(Debug, Clone)] struct ConversationAgent { llm: Arcdyn LLMClient, history: ArcRwLockVecMessage, } #[async_trait] impl AsyncAgent for ConversationAgent { type State (); async fn handle_message( self, _context: AgentContext, messages: [Message], ) - ResultAgentResponse { let mut history self.history.write().await; history.extend_from_slice(messages); let reply self.llm.chat(history.as_slice()).await?; history.push(Message::assistant(reply.clone())); Ok(AgentResponse { content: reply, ..Default::default() }) } } #[tokio::main] async fn main() - Result() { tracing_subscriber::fmt().init(); let llm_client OpenAIChatCompletionsClient::builder() .api_key(std::env::var(LLM_API_KEY).unwrap_or_default()) .base_url(std::env::var(LLM_BASE_URL).unwrap_or_default()) .model(qwen2.5-72b-instruct) .build()?; let agent ConversationAgent { llm: Arc::new(llm_client), history: Arc::new(RwLock::new(Vec::new())), }; let context DefaultAgentContext::builder() .llm_client(agent.llm.clone()) .build()?; let runtime AgentRuntime::builder() .agent(agent) .context(context) .build()?; let response runtime.run(你好我叫小明).await?; println!(Agent: {}, response.content); let response runtime.run(我叫什么名字).await?; println!(Agent: {}, response.content); Ok(()) }这个示例的核心思路就一句话把消息历史放在 Agent 内部的ArcRwLock里每轮对话先追加消息再调用 LLM再把回复追加回去。这样 Agent 就具备了最基本的记忆能力。4.2 关键步骤拆解Agent 服务启动流程跑上面这段代码完整流程是这样的初始化 tracing让日志能输出到标准输出。读取环境变量里的 API 密钥和端点地址构建 LLM 客户端。创建 Agent 实例初始化共享的历史记录容器。创建上下文构建器设置 LLM 客户端。构建运行时把 Agent 和上上下文绑定。调用runtime.run()让 Agent 处理用户输入。这里面第 2 步有个细节直接用unwrap_or_default()处理环境变量缺失个人习惯是在实际项目中改成expect(LLM_API_KEY must be set)宁可启动时快速失败也不要运行到一半才报错。4.3 参数选择的逻辑你可能注意到了我在构建客户端时没有硬编码模型名和温度值而是放在了环境变量和配置层。这是我自己的实践习惯模型名会频繁更换温度需要按场景调优这些都应该在配置层面解决而不是在代码层面。以我自己的项目为例模型从 7B 切换到 72B温度从 0.8 降到 0.3全程只需要修改环境变量和配置文件然后重启服务不需要改任何代码。如果你把参数写死在代码里每次调优都要走一次编译部署流程迭代效率一下就没了。5. 常见问题与排查技巧实录5.1 编译期借用与生命周期冲突Rust 新人上手 adk-rust 时最常遇到的就是生命周期报错。比如在handle_message里想同时持有 Agent 的某个字段和 context 里的某个数据编译器会告诉你“不能在self的生命周期内返回self内部的引用”。这类问题的解决思路很简单要么把所有需要共享的数据包进ArcRwLockT要么把数据的所有权转移进来。我的经验是所有可能被多个异步任务接触的数据一律走Arc包裹不要在 Agent 结构体里尝试存储引用类型。5.2 运行时阻塞问题Rust 异步运行时对“你在 await 中执行 CPU 密集型操作”是零容忍的。如果你在handle_message里直接做重计算比如解析大型响应、做复杂的正则匹配、处理大数组就会阻塞 tokio 的 worker 线程导致整个服务的吞吐量下降。正确做法是把 CPU 密集任务丢到spawn_blocking里let result tokio::task::spawn_blocking(move || { // 这里做重计算 heavy_computation(input) }).await?;5.3 LLM 调用超时与重试LLM 接口不像本地函数外部服务的稳定性是不可控的。我在实测中就遇到过模型推理超时、上游限流、网络抖动。因此强烈建议在 LLM 客户端外层做两件事超时控制和重试机制。adk-rust 自身的客户端没有内置复杂的重试策略我习惯自己包装一层async fn call_llm_with_retry( llm: dyn LLMClient, messages: [Message], max_retries: usize, ) - ResultString { let mut retries 0; loop { match llm.chat(messages).await { Ok(reply) return Ok(reply), Err(e) if retries max_retries { retries 1; tracing::warn!(LLM call failed ({}), retry {}/{}, e, retries, max_retries); tokio::time::sleep(std::time::Duration::from_secs(2 * retries)).await; } Err(e) return Err(e), } } }重试策略里我用的是指数退避每次重试等 2 秒、4 秒、8 秒。这个策略在外部服务抖动时表现不错既避免了立即重试撞上窗口期也避免了频繁请求给上游加压。5.4 常见问题速查表现象可能原因解决方案编译报错找不到AsyncAgenttrait没有引入 prelude 或async-trait依赖检查 use 和 Cargo.toml运行时无响应LLM 地址配置错误或网络不通检查 base_url 是否可访问用 curl 测试并发会话互相串话Agent 状态存放在结构体字段中且未做隔离改用会话 ID 做状态分片内存持续增长消息历史无限追加设置历史窗口超过长度丢旧消息首次调用慢得离谱LLM 客户端懒加载或连接池未预热预热连接或开启连接池复用5.5 独家避坑心得这里说几个常见文档里不会写的点。第一adk-rust 的Message类型不是只有user和assistant两种变体还有system和tool。如果你对接的 LLM 是严格按角色区分消息的要注意在拼接历史时保留工具调用产生的消息否则模型会失去对工具执行结果的感知。第二AgentResponse不是只承载文本。它还支持附加结构化输出、元数据、agent 间通信的信息。如果你在做多 Agent 协作一定不要只把目光放在content字段上多看几个字段很多上下文传递的坑能提前避开。第三生产环境中不要用默认的RwLock处理高频写入的历史记录。读多写少的场景RwLock表现良好但如果你的场景是每轮对话都要追加多条消息高频写建议直接换成tokio::sync::Mutex。我看过性能分析在高写场景下RwLock的写锁竞争反而拖慢吞吐。6. 进阶扩展多 Agent 协作与工具调用6.1 多 Agent 编排的基本模式单 Agent 只能响应简单的“单轮问答 记忆”场景。现实中很多任务需要拆解成多个子任务每个子任务由独立的 Agent 承担。这就要用到多 Agent 编排。adk-rust 没有特别复杂的编排 DSL它的思路很直接每个 Agent 就是一个独立的结构体实例你可以手动定义执行顺序、传递消息、收集结果。我用的是非常经典的生产者-消费者模式use tokio::sync::mpsc; #[derive(Debug, Clone)] enum Task { Analyze(String), Generate(String), } #[derive(Debug, Clone)] struct Orchestrator { analyzer: ArcAnalyzerAgent, generator: ArcGeneratorAgent, } impl Orchestrator { async fn run(self, input: String) - ResultString { let (tx, mut rx) mpsc::channel::Task(8); let analyzer self.analyzer.clone(); let tx_clone tx.clone(); let analyzer_handle tokio::spawn(async move { let analysis analyzer.analyze(input).await?; tx_clone.send(Task::Analyze(analysis)).await?; Ok::_, anyhow::Error(()) }); let generator self.generator.clone(); let generator_handle tokio::spawn(async move { let task rx.recv().await.unwrap(); let Task::Analyze(analysis) task; let result generator.generate(analysis).await?; Ok::_, anyhow::Error(result) }); let _ analyzer_handle.await?; let result generator_handle.await??; Ok(result) } }这种写法的好处是显式、可控适合中小型系统。如果编排关系非常复杂建议引入工作流引擎但那种复杂度可能就不适合硬塞在 Agent 层了需要单独设计。6.2 工具调用给 Agent 装上“手”Agent 不能只动嘴还要能动手。adk-rust 的工具调用机制和大多数 Agent 框架类似注册函数描述用途和参数Agent 在合适的时候主动调用。看一个最简单的工具注册示例use adk::tools::Tool; use serde_json::json; #[derive(Debug, Clone)] struct WeatherTool; impl Tool for WeatherTool { fn name(self) - String { get_weather.to_string() } fn description(self) - String { 查询指定城市的天气情况.to_string() } fn parameters(self) - serde_json::Value { json!({ type: object, properties: { city: { type: string, description: 城市名称 } }, required: [city] }) } }然后把这个工具挂到 Agent 上Agent 需要时就会生成工具调用请求运行时帮忙执行并把结果回传给模型。这里有个关键认知工具调用的本质是一个闭环。模型输出“我要调用 get_weathercity北京”运行时执行工具返回结果“晴26 度”模型再基于这个结果生成最终回复。这个闭环里任何一环断了Agent 的回答就不可靠。6.3 并发策略从单会话到多会话的演进如果你写完单 Agent 后直接上线多半会遇到“某个会话卡住拖垮所有请求”的教训。我自己就是这么过来的。一个用户长时间占用模型推理时其他用户的请求全被堵在后面。解决思路有两个方向方向一每个会话一个独立任务。主 loop 接收到请求后tokio::spawn一个任务来处理互不干涉。这样某个会话再慢也不影响其他会话。这也是我线上的做法。方向二引入消息队列做缓冲。请求先投递到队列消费者从队列取任务执行。优点是可以控制并发度平滑处理突发流量。let task_handle tokio::spawn(async move { let response runtime.run(input).await?; sender.send(response).await?; Ok::_, anyhow::Error(()) });并发不是越强越好要看下游服务的承受能力。我见过有人用 Rust 单实例同时开几千个并发结果把模型服务的 API 打崩最后还是要限制并发数。我个人的体会是adk-rust 最大的价值不在“能写 Agent”这个表面功能上而在于它把 Agent 服务变成了一个普通的 Rust 异步服务。你不需要再纠结 GIL、不需要再处理跨语言调用只需要专注 Agent 逻辑本身。我在实际项目中用到的记忆系统、多 Agent 协作、工具编排都可以在这个框架下自然生长而不用额外引入重量级基础设施。如果你是第一次尝试用 Rust 写 Agent建议先从小场景切入比如一个带记忆的单 Agent 问答服务跑通了再逐步加工具、加多 Agent 编排。这样每一步的爆炸半径都可控排查起来也不会太费劲。踩过几次坑之后你就会发现Rust 类型系统在 Agent 领域里不但不是束缚反而是保护——很多 Python 方案里上线后才暴露的问题在编译阶段就已经被拦截掉了。