用 Rust 写 Agent 服务 -- adk-rust 上手记

前阵子我给手上一个 Rust 服务加了一个 Agent 能力。用户用自然语言问一句,服务端自己挑工具、记状态,把结果流式吐回来。以前这类活儿我会直接开 Python,agent 的生态都在那边,这次不行,这段能力要嵌进一个已经在跑的服务里。为了一个功能再维护一个 Python 进程、一套依赖、一条跨语言的调用链,还得盯着它的内存和冷启动,账不划算,于是拿 Rust 试。框架挑了 adk-rust(Apache-2.0,43 个 crate,模型无关)。整个服务最后长这样。


先跑起来再说

我这个人的习惯是先跑起来,再研究它内部怎么实现。adk-rust 有官方脚手架,第一步没什么可想的。

bash 复制代码
cargo install cargo-adk
cargo adk new adk-agent-service --template api --provider deepseek
cd adk-agent-service && cp .env.example .env   # 填 DEEPSEEK_API_KEY
cargo run                                      # http://127.0.0.1:8080/ui/

装脚手架编译了一分半,生成骨架一秒,首次 cargo build 35 秒。打开 http://127.0.0.1:8080/ui/,页面上有个输入框,我敲了句"你好",它回我了。我本来以为要先啃文档,先弄明白 Runner、Agent、Session 这三层抽象,结果脚手架生成的 main.rs 已经把路由、SSE 和内置 Web UI 全接好了,我要改的只有模型和指令。

rust 复制代码
let agent: Arc<dyn Agent> = Arc::new(
    LlmAgentBuilder::new("adk-agent-service")
        .instruction("You are a helpful assistant...")
        .model(Arc::new(model))
        .build()?,
);
let config = ServerConfig::new(Arc::new(adk_rust::SingleAgentLoader::new(agent)), session_service);
let app = create_app(config);

从那时候起我就知道这个项目能做成,剩下的都是"怎么做得像样"的问题。模型这块没什么可纠结的,adk-model 里 Gemini、OpenAI、Anthropic、DeepSeek、Groq、Ollama、Bedrock 都是同一套接法,我选 DeepSeek 纯粹因为手上有 key,后面想试 deepseek-v4-pro,也只改了 .env 里一行。


工具就是给模型写的说明书

脚手架给的 agent 只会聊天,得给它工具。adk-rust 的 #[tool] 宏是我最喜欢的一部分,因为它一次做完了三件琐事。函数名变成工具名,文档注释变成工具描述,参数类型的 JsonSchema 变成参数声明。

rust 复制代码
#[derive(Deserialize, JsonSchema)]
pub struct CurrentTimeArgs {
    /// Offset from UTC in hours, e.g. 8 for Beijing, -5 for New York. Defaults to 0 (UTC).
    pub offset_hours: Option<f64>,
}

/// Return the current date and time, in UTC and at a requested UTC offset.
#[tool(read_only, concurrency_safe)]
pub async fn current_time(args: CurrentTimeArgs) -> Result<Value, AdkError> { ... }

注册加到 builder 上,一行一个。

rust 复制代码
.tool(Arc::new(tools::CurrentTime))
.tool(Arc::new(tools::Calculate))
.tool(Arc::new(tools::Remember))
.tool(Arc::new(tools::Recall))

这里有个动作我建议你也做一次,把模型实际收到的工具声明打印出来看看,写个临时测试就行。

rust 复制代码
fn dump(tool: &dyn Tool) {
    println!("--- {} ---", tool.name());
    println!("description: {}", tool.description());
    println!("schema: {}", serde_json::to_string(&tool.parameters_schema()).unwrap());
    println!("read_only={} concurrency_safe={}", tool.is_read_only(), tool.is_concurrency_safe());
}

看到输出之后我改了两个习惯。一是参数上的文档注释真的会进 schema,模型就是靠它决定怎么填的,别偷懒写 "the value"。二是 remember 这个工具会写数据,不能标 read_only,标了它就可能被并发派发;而 current_timecalculate 这种纯读取的标上之后,模型可以在一轮里同时调用它们。我问了句"现在 UTC+8 几点?算一下 (12.5 + 7) * 3^2 / 4。记住交付日是周五。再把笔记念一遍。"下面就是那一轮的事件流。

模型第一回合直接并发甩出三个调用,时间、计算、记笔记各一个,拿到结果后再决定调一次 recall,最后才给答案。三个只读工具是并行执行的,靠的就是前面那两个标记。我第一次看到这段事件流时确实"哦"了一声,并发派发不用我自己调度,框架看着工具元数据就定了。


笔记存哪儿

remember / recall 这对工具我一开始想得很简单,搞个全局 HashMap<session_id, Vec<String>> 不就完了。写之前多看了一眼框架,发现工具拿到的是 ToolContext,它能写会话状态。

rust 复制代码
#[tool]
pub async fn remember(ctx: Arc<dyn ToolContext>, args: RememberArgs) -> Result<Value, AdkError> {
    let note = args.note.trim();
    if note.is_empty() {
        return Err(AdkError::tool("note must not be empty"));
    }
    let mut notes = notes(&*ctx);
    notes.push(json!(note));

    let mut actions = ctx.actions();
    actions
        .state_delta
        .insert(NOTES_KEY.to_string(), json!(notes));
    ctx.set_actions(actions);

    Ok(json!({ "notes": notes, "count": notes.len() }))
}

工具只负责写一个 delta,剩下的事 runner 干。session 生命周期不用我自己管,并发不用我自己考虑,换存储后端(内存、SQLite、Postgres、Redis)时工具一行都不用改。后来我把后端换成 SQLite,工具代码确实一个字没动。


坑一,工具里读会话状态,ctx.state() 永远是 None

remember 写完,recall 读不出来。它永远返回 count: 0,可我用 curl 查会话,state.notes 里明明有数据。我盯着那个 count: 0 看了半天,先怀疑自己 key 写错了,又怀疑 delta 没提交,都不是,最后翻源码找到了答案。

adk-rust 交给工具的其实是个 AgentToolContext。它的 ReadonlyContext 实现里,user_id()session_id() 都很规矩地委托给了父上下文,唯独 state() 没有覆写,于是拿到的是 trait 的默认实现,None。也就是说,ToolContext::state() 这个方法文档上有,签名上能用,但在这个运行时里永远返回空,不报错,不警告,只是你的功能安静地失效。修法绕开它,从 session() 里把 state 捞出来,state() 留作兜底。

rust 复制代码
fn notes(ctx: &dyn ToolContext) -> Vec<Value> {
    let state = ctx
        .state()
        .or_else(|| ctx.session().map(|session| session.state()));
    state
        .and_then(|state| state.get(NOTES_KEY))
        .and_then(|value| value.as_array().cloned())
        .unwrap_or_default()
}

一行兜底,recall 立刻正常。这件事给我留下的印象比 bug 本身深,Option 是个不报错的失败。框架把能力挂在 trait 上,有默认实现,也向后兼容,不等于运行时真的实现了它。以后拿到 Option,我会先验一遍它在真实调用里到底是不是 Some


坑二,最难受的一次排查,"界面没反应"

那天我在浏览器里打开界面,创建会话、发消息,什么都没发生。没有回复,没有转圈,屏幕上连个错误也没有,输入框还在那儿,像刚才那句根本没敲过。站在用户角度就是四个字,界面没反应。

我先去查服务端日志,发现更奇怪的事,日志里根本没有这次请求。那就说明请求没打到我以为的那个进程。一查端口,果然,8080 是我起的验证实例,浏览器连的是 8090,另一个实例,用的是同一份代码。找到它之后,我在那个实例上重放了一次界面会发的请求。

bash 复制代码
$ curl -sN -X POST .../api/run_sse -H 'x-adk-ui-protocol: adk_ui' -d '{...}'
HTTP 200  content-type: text/event-stream
bytes=0

HTTP 200,body 0 字节。

那一刻我明白前端为什么没反应了。它拿到的是一个成功的响应,一个空的事件流,没有事件就没东西渲染,没有错误就没东西提示。前端一点问题都没有,它只是诚实地展示了一个什么都没有的服务端。

继续挖,根因有两层。第一层很蠢,那个实例的 .envDEEPSEEK_API_KEY 是空的,复制的 .env.example,忘了填。第二层才是真正的问题,模型 401 这个错误没有出现在事件流里,adk-server 把 provider 的异常挡在了 SSE 之外,调用方完全无法区分"模型说我 key 不对"和"模型无话可说"。

修法我选了最直接的那种,启动时先探测一次模型,让配置问题在启动阶段就暴露,而不是等第一个用户来踩。

rust 复制代码
let probe = LlmRequest {
    model: model_id.clone(),
    contents: vec![Content::new("user").with_text("ping")],
    config: Some(GenerateContentConfig { max_output_tokens: Some(1), ..Default::default() }),
    tools: HashMap::new(),
    previous_response_id: None,
};
match model.generate_content(probe, false).await?.next().await {
    Some(Ok(_)) => tracing::info!(model = %model_id, "provider probe ok"),
    Some(Err(e)) => anyhow::bail!("DeepSeek rejected the startup probe for '{model_id}': {e}"),
    None => anyhow::bail!("DeepSeek returned an empty startup probe for '{model_id}'"),
}

代价是一次 max_output_tokens=1 的调用,换来的是下面这两行。

text 复制代码
$ DEEPSEEK_API_KEY= ./target/debug/adk-agent-service
Error: DEEPSEEK_API_KEY is unset or empty --- copy .env.example to .env and fill in your key

$ DEEPSEEK_API_KEY=sk-bogus ./target/debug/adk-agent-service
Error: DeepSeek rejected the startup probe for 'deepseek-v4-flash':
       model.unauthorized: DeepSeek API error (HTTP 401 Unauthorized)

顺便我还补了一个空值判断,std::env::var() 对空字符串返回的是 Ok("") 而不是错误,不判断的话服务会带着空 key 高高兴兴地起来。

这件事教给我一条规矩。一个"HTTP 200 但什么都没发生"的接口,排查成本比"启动就报错"高一个数量级。宁可启动失败,也别让服务带着坏配置对外提供静默失败,凡是一次请求产出 0 个事件,就该被当成异常告警。


界面为什么是中文的

界面本来是英文的。我一开始没打算动它,能跑就行。但这服务是给团队内部用的,让同事对着 "Runtime target"、"Interactive run"、"Leaf agent · no child runtime targets" 猜意思,不如我花点时间。

动手了才发现这事比想象的麻烦。上游 UI 是一个 React 单页应用,字符串硬编码,没有 i18n;adk-servercreate_app() 内部无条件挂载自己的 /ui 路由,ServerConfig 只暴露一个 backend_url,没有关闭开关。所以只能 fork 之后自己托管。

具体是六步。按官方 v2.2.0 tag 把源码取到项目里,翻译(App.tsx 96 处、api.ts 2 处、index.htmllang 和标题),把 vite 产物改成固定文件名,用 include_bytes! 内嵌进二进制,加一层 middleware 短路 /ui/ 下面的四个路径(/ui//ui/index.html/ui/assets/ui.js/ui/assets/ui.css),头部照抄框架的 CSP 策略。

翻译时我特意做了一件事,只翻译显示文本,内部标识一个不动。runningagenttopology 这些状态值仍然是英文,它们同时也是 CSS 类名和逻辑判断的依据,前面加三个映射常量负责显示,逻辑零改动。还有一步我认为特别值,保真校验。fork 别人的代码最怕的就是"我这到底是不是它真正在用的那份",所以我用 npm ci 锁死工具链,把构建产物的 md5 和 crates.ioadk-server 2.2.0 里内嵌的 bundle 对比。

text 复制代码
vendor 源码构建:  e60ed292ee6741cddb643400a6294500  index-CAmeE16-.js
crates.io 2.2.0:  e60ed292ee6741cddb643400a6294500  index-CAmeE16-.js
CSS 未改动:       8db1317dd99bf1793941757c464e5039  (与上游一致)

逐字节一致,说明我的起点就是框架实际内嵌的那份 UI。CSS 也没变,我只改了文案,没碰样式,这比"看着差不多"踏实多了。

这里建议你补一张截图cargo run 之后打开 http://127.0.0.1:8080/ui/,发一句"现在 UTC+8 几点?再算 (12.5+7)*3^2/4",把中文界面和工具调用折叠块截下来。这是全篇最有说服力的一张图。


上线前补的四件事

功能能跑之后,我按"放到真实环境会怎样"的顺序过了一遍,补了四件事。

会话别再存在内存里。 默认的 InMemorySessionService 一重启就清空,开发时无所谓,但"聊到一半服务重启、上下文全丢"这种体验不能给用户。换成 SQLite 只改了一处,ServerConfig::new 收的本来就是 Arc<dyn SessionService>

rust 复制代码
let store = SqliteSessionService::new(&format!("sqlite://{path}?mode=rwc")).await?;
store.migrate().await?;   // 建表必须显式调用

这里有个小坑,migrate() 不调的话不会报错,第一轮写入时才炸。实测下来,写完笔记重启进程,GET /api/sessions/... 依然是 6 条事件加 state.notes,接着聊没问题,后来在容器里 stop / start 也验了一遍。

让进程能被正常停掉。 podman stop 每次都要等满 10 秒,然后 SIGKILL。我一开始怀疑镜像写错了,查完才发现原因很物理,容器里进程是 PID 1,而 PID 1 会忽略自己没有接管处置的信号;axum::serve 默认不装 SIGTERM handler,停止信号被丢掉,运行时只能超时强杀。补上 handler 之后,本地 SIGTERM 是退出码 0、7 毫秒退出,podman stop 从 10 秒变成 184 毫秒。

容器化。 Rust 项目做 Docker 最烦的是"改一行代码、重编三百个依赖",把 Dockerfile 拆成依赖层加源码层就好了,先用占位 main.rs 只编依赖,之后再换真实源码。

dockerfile 复制代码
# 依赖层:先只放清单 + 占位 main.rs
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src webui/dist \
 && echo 'fn main() {}' > src/main.rs \
 && cargo build --release --locked \
 && rm -rf src

# 源码层:只重编本 crate
COPY src ./src
COPY webui/dist ./webui/dist
RUN touch src/main.rs && cargo build --release --locked

因为界面产物是随仓库提交的,构建阶段连 Node 都不需要。运行镜像只有 113 MB,构建阶段的 900 MB 不进镜像,首次全量构建约两分钟,之后只改 src/ 只要 5.5 秒。

让它还是一个文件。 include_bytes! 把界面塞进二进制,部署就是拷一个文件。ldd 出来只有 libc、libm、libgcc,SQLite 静态链接在里面,TLS 是纯 Rust 实现。代价是改文案要重新构建,对服务端项目我觉得划算。


用下来的感受

如果只用一句话概括,这个框架写代码很快,排障很慢,两件事完全不在一个量级。

快的部分是真的快。从装脚手架到第一句回复,我中间没卡过壳。工具写起来几乎不用想太多,遇到不确定的 API 我基本是直接翻源码,~/.cargo/registry/src/ 里就有,比翻文档快也更准。换模型、换会话后端都是"改一行"级别的操作,这个抽象层次是我满意的。

慢的部分基本都花在"什么都不发生"和"不报错的失败"上。那次 401 变空响应,我前后花了四十分钟,含两个实例对照和抓 SSE 原始帧;文档和实现不一致,照着官方文档写的请求体拿到 422,又花了十几分钟;ctx.state() 恒为 None 那种静默失效,二十分钟定位。

后来我总结出一个整体印象。这个框架倾向于沉默。它不骗你,但也不主动提醒你,Option 悄悄返回 None,provider 报错被挡在事件流之外,feature 名字差一个词就走默认分支。对于习惯读源码的 Rust 开发者,这些都能解决;如果你期望"文档说啥就是啥",会比较难受。


优点和缺点

它的优点是我留下来继续用的理由。模型无关,换 provider 改一行;工具开发成本低,#[tool] 把命名、描述、schema 一次做完;运行时薄,启动到 ready 一秒内,官方给的循环开销是 568 μs;会话抽象干净;单二进制交付,113 MB 的镜像;能力面覆盖广,MCP、A2A、RAG、评测、遥测、鉴权、实时语音都有对应 crate;文档和示例也不少,v2.2.0 这个 tag 的 examples/ 目录下有 110 多个可运行示例,playground 里另有 120 多个,我查 API 时翻到过好几次。

它的缺点是你要提前知道的成本。

  • 失败静默是最痛的。provider 异常不进事件流,200 加 0 字节,前端和调用方都察觉不到。
  • 文档与代码有出入。同一接口在不同文档里字段风格都不一样,建议直接读 rest/controllers/* 源码。
  • trait 默认实现会埋雷。AgentToolContext 没覆写 state(),工具读会话状态恒为空。
  • feature 组合有缺口。伞 crate 有 postgres-session 却没有 sqlite-session,得单独依赖 adk-session
  • 内置 UI 不可配置。没有 i18n,/ui 也关不掉,想中文化只能 fork。
  • 容器化的细节没覆盖。PID 1 忽略 SIGTERM 这种坑要自己踩一遍。
  • 还有模块是占位的。我在源码里见过 rest/routes.rs 只有一行注释残留,cargo-adk/src/cli.rs 写着 "Will be implemented in a later task"。上生产前最好确认一下你要用的模块是不是已完成状态。

适合谁。要求单二进制、低内存、快冷启动的场景,比如边缘网关、CLI 工具、内嵌服务;已经有 Rust 服务、想加 agent 能力也行;以及愿意读源码的人。

不适合谁。主要还是"快速试验 prompt 和流程"的团队,那种节奏下 Python 生态的迭代速度优势是实打实的。强依赖某个 Python-only 集成的场景也一样。另外,团队完全没有 Rust 经验的话,要还的债是 Async、Trait 对象和 feature 组合这三样,得有心理准备。


如果重来一次

  1. 先写启动探测(十行代码),再写业务。这一条能省掉我那次最难受的排查。
  2. 先用浏览器 F12 看一遍内置界面到底打哪个 endpoint、body 长什么样,再动手写客户端,比读文档快一个数量级。
  3. 先把 parameters_schema() 打印出来核对,那才是模型看到的契约。
  4. 会话后端一开始就用 SQLite。内存后端只适合 CI,"重启丢数据"会掩盖很多问题,包括你以为已经写完的功能。
  5. 容器化当天就把 SIGTERM 处理掉,否则每次发布都要等满超时。
  6. 给"空响应"加监控。一次请求产出 0 个事件,应该被当成异常。

最后

整个项目最后是这个样子,src/ 三个文件、543 行代码,换来一个自带中文界面、会话可持久化、能塞进容器、单文件部署的 agent 服务。Rust 写 agent 现在确实不像 Python 那么"伸手就有",但能塞进现有服务、一个二进制跑起来、没有额外运行时,这件事是别的方案不太容易给的。adk-rust 有它的粗糙处,上面那些坑都是真的,用下来的整体感受是它在往对的方向长。

代码和配图都在我本地的 adk-agent-service/ 目录里,想复现的话按文中的命令走一遍就行。有问题欢迎评论区聊,尤其如果你也踩过"200 但什么都没发生"这种坑,我很想知道你当时是怎么定位的。


(参考 adk-rust 仓库 · crates.io · docs.rs

相关推荐
杨运交1 小时前
[071][验证码模块]基于Spring拦截器的验证码认证设计思想
java·后端·spring
LucianaiB2 小时前
我用豆包工作 Seed-2.1-pro,复刻了 QQ 时代爆红的魔术图片
后端
码事漫谈2 小时前
智谱 ZCode 静默上传 Git 历史:48 小时信任危机复盘
后端
掘金码甲哥2 小时前
当对话模型遇上向量模型,vllm production stack 又该如何应对?
后端
传奇开心果编程3 小时前
【springboot基础语法学与练】第 1 课:从零开始
java·spring boot·后端·学习
IT_陈寒3 小时前
Java线程池用错参数,我的服务居然悄悄崩溃了
前端·人工智能·后端
光影少年3 小时前
Express 中间件原理
后端·node.js·express
梦在远山后3 小时前
Electron 与 FastAPI 如何完成流式 Agent 对话
python·langchain·agent
aramae4 小时前
MySQL内置函数(7)
开发语言·笔记·后端·mysql·其他