用 Rust 写 Agent 服务 -- adk-rust 上手记
英文标题:用 Rust 写 Agent 服务 -- adk-rust 上手记
用 adk-rust 给已有服务加 Agent 能力。难在两个不报错的失败,ctx.state() 恒为 None,模型 401 变成 HTTP 200 空事件流。另附中文界面改造和生产化实测。
28.7 KB
55.0 KB
33.7 KB
40.2 KB
56.4 KB
43.0 KB
55.7 KB
42.0 KB
中文
前阵子我给手上一个 Rust 服务加了一个 Agent 能力。用户用自然语言问一句,服务端自己挑工具、记状态,把结果流式吐回来。以前这类活儿我会直接开 Python,agent 的生态都在那边,这次不行,这段能力要嵌进一个已经在跑的服务里。为了一个功能再维护一个 Python 进程、一套依赖、一条跨语言的调用链,还得盯着它的内存和冷启动,账不划算,于是拿 Rust 试。框架挑了 adk-rust(Apache-2.0,43 个 crate,模型无关)。整个服务最后长这样。
先跑起来再说
我这个人的习惯是先跑起来,再研究它内部怎么实现。adk-rust 有官方脚手架,第一步没什么可想的。
cargo install cargo-adk
cargo adk new adk-agent-service --template api --provider deepseek
cd adk-agent-service && cp .env.example .env
cargo run
装脚手架编译了一分半,生成骨架一秒,首次 cargo build 35 秒。打开 http://127.0.0.1:8080/ui/,页面上有个输入框,我敲了句“你好”,它回我了。我本来以为要先啃文档,先弄明白 Runner、Agent、Session 这三层抽象,结果脚手架生成的 main.rs 已经把路由、SSE 和内置 Web UI 全接好了,我要改的只有模型和指令。
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 变成参数声明。
#[derive(Deserialize, JsonSchema)]
pub struct CurrentTimeArgs {
pub offset_hours: Option<f64>,
}
#[tool(read_only, concurrency_safe)]
pub async fn current_time(args: CurrentTimeArgs) -> Result<Value, AdkError> { … }
注册加到 builder 上,一行一个。
.tool(Arc::new(tools::CurrentTime))
.tool(Arc::new(tools::Calculate))
.tool(Arc::new(tools::Remember))
.tool(Arc::new(tools::Recall))
这里有个动作我建议你也做一次,把模型实际收到的工具声明打印出来看看,写个临时测试就行。
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_time、calculate 这种纯读取的标上之后,模型可以在一轮里同时调用它们。我问了句“现在 UTC+8 几点?算一下 (12.5 + 7) * 3^2 / 4。记住交付日是周五。再把笔记念一遍。”下面就是那一轮的事件流。
模型第一回合直接并发甩出三个调用,时间、计算、记笔记各一个,拿到结果后再决定调一次 recall,最后才给答案。三个只读工具是并行执行的,靠的就是前面那两个标记。我第一次看到这段事件流时确实“哦”了一声,并发派发不用我自己调度,框架看着工具元数据就定了。
笔记存哪儿
remember / recall 这对工具我一开始想得很简单,搞个全局 HashMap<session_id, Vec<String>> 不就完了。写之前多看了一眼框架,发现工具拿到的是 ToolContext,它能写会话状态。
#[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() 留作兜底。
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,另一个实例,用的是同一份代码。找到它之后,我在那个实例上重放了一次界面会发的请求。
$ 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 字节。
那一刻我明白前端为什么没反应了。它拿到的是一个成功的响应,一个空的事件流,没有事件就没东西渲染,没有错误就没东西提示。前端一点问题都没有,它只是诚实地展示了一个什么都没有的服务端。
继续挖,根因有两层。第一层很蠢,那个实例的 .env 里 DEEPSEEK_API_KEY 是空的,复制的 .env.example,忘了填。第二层才是真正的问题,模型 401 这个错误没有出现在事件流里,adk-server 把 provider 的异常挡在了 SSE 之外,调用方完全无法区分“模型说我 key 不对”和“模型无话可说”。
修法我选了最直接的那种,启动时先探测一次模型,让配置问题在启动阶段就暴露,而不是等第一个用户来踩。
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 的调用,换来的是下面这两行。
$ 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-server 在 create_app() 内部无条件挂载自己的 /ui 路由,ServerConfig 只暴露一个 backend_url,没有关闭开关。所以只能 fork 之后自己托管。
具体是六步。按官方 v2.2.0 tag 把源码取到项目里,翻译(App.tsx 96 处、api.ts 2 处、index.html 的 lang 和标题),把 vite 产物改成固定文件名,用 include_bytes! 内嵌进二进制,加一层 middleware 短路 /ui/ 下面的四个路径(/ui/、/ui/index.html、/ui/assets/ui.js、/ui/assets/ui.css),头部照抄框架的 CSP 策略。
翻译时我特意做了一件事,只翻译显示文本,内部标识一个不动。running、agent、topology 这些状态值仍然是英文,它们同时也是 CSS 类名和逻辑判断的依据,前面加三个映射常量负责显示,逻辑零改动。还有一步我认为特别值,保真校验。fork 别人的代码最怕的就是“我这到底是不是它真正在用的那份”,所以我用 npm ci 锁死工具链,把构建产物的 md5 和 crates.io 上 adk-server 2.2.0 里内嵌的 bundle 对比。
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>。
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 只编依赖,之后再换真实源码。
# 依赖层:先只放清单 + 占位 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 组合这三样,得有心理准备。
如果重来一次
- 先写启动探测(十行代码),再写业务。这一条能省掉我那次最难受的排查。
- 先用浏览器 F12 看一遍内置界面到底打哪个 endpoint、body 长什么样,再动手写客户端,比读文档快一个数量级。
- 先把
parameters_schema()打印出来核对,那才是模型看到的契约。 - 会话后端一开始就用 SQLite。内存后端只适合 CI,“重启丢数据”会掩盖很多问题,包括你以为已经写完的功能。
- 容器化当天就把 SIGTERM 处理掉,否则每次发布都要等满超时。
- 给“空响应”加监控。一次请求产出 0 个事件,应该被当成异常。
最后
整个项目最后是这个样子,src/ 三个文件、543 行代码,换来一个自带中文界面、会话可持久化、能塞进容器、单文件部署的 agent 服务。Rust 写 agent 现在确实不像 Python 那么“伸手就有”,但能塞进现有服务、一个二进制跑起来、没有额外运行时,这件事是别的方案不太容易给的。adk-rust 有它的粗糙处,上面那些坑都是真的,用下来的整体感受是它在往对的方向长。
代码和配图都在我本地的 adk-agent-service/ 目录里,想复现的话按文中的命令走一遍就行。有问题欢迎评论区聊,尤其如果你也踩过“200 但什么都没发生”这种坑,我很想知道你当时是怎么定位的。
(参考 adk-rust 仓库 · crates.io · docs.rs )
English / 原文
前阵子我给手上一个 Rust 服务加了一个 Agent 能力。用户用自然语言问一句,服务端自己挑工具、记状态,把结果流式吐回来。以前这类活儿我会直接开 Python,agent 的生态都在那边,这次不行,这段能力要嵌进一个已经在跑的服务里。为了一个功能再维护一个 Python 进程、一套依赖、一条跨语言的调用链,还得盯着它的内存和冷启动,账不划算,于是拿 Rust 试。框架挑了 adk-rust(Apache-2.0,43 个 crate,模型无关)。整个服务最后长这样。
先跑起来再说
我这个人的习惯是先跑起来,再研究它内部怎么实现。adk-rust 有官方脚手架,第一步没什么可想的。
cargo install cargo-adk
cargo adk new adk-agent-service --template api --provider deepseek
cd adk-agent-service && cp .env.example .env
cargo run
装脚手架编译了一分半,生成骨架一秒,首次 cargo build 35 秒。打开 http://127.0.0.1:8080/ui/,页面上有个输入框,我敲了句“你好”,它回我了。我本来以为要先啃文档,先弄明白 Runner、Agent、Session 这三层抽象,结果脚手架生成的 main.rs 已经把路由、SSE 和内置 Web UI 全接好了,我要改的只有模型和指令。
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 变成参数声明。
#[derive(Deserialize, JsonSchema)]
pub struct CurrentTimeArgs {
pub offset_hours: Option<f64>,
}
#[tool(read_only, concurrency_safe)]
pub async fn current_time(args: CurrentTimeArgs) -> Result<Value, AdkError> { … }
注册加到 builder 上,一行一个。
.tool(Arc::new(tools::CurrentTime))
.tool(Arc::new(tools::Calculate))
.tool(Arc::new(tools::Remember))
.tool(Arc::new(tools::Recall))
这里有个动作我建议你也做一次,把模型实际收到的工具声明打印出来看看,写个临时测试就行。
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_time、calculate 这种纯读取的标上之后,模型可以在一轮里同时调用它们。我问了句“现在 UTC+8 几点?算一下 (12.5 + 7) * 3^2 / 4。记住交付日是周五。再把笔记念一遍。”下面就是那一轮的事件流。
模型第一回合直接并发甩出三个调用,时间、计算、记笔记各一个,拿到结果后再决定调一次 recall,最后才给答案。三个只读工具是并行执行的,靠的就是前面那两个标记。我第一次看到这段事件流时确实“哦”了一声,并发派发不用我自己调度,框架看着工具元数据就定了。
笔记存哪儿
remember / recall 这对工具我一开始想得很简单,搞个全局 HashMap<session_id, Vec<String>> 不就完了。写之前多看了一眼框架,发现工具拿到的是 ToolContext,它能写会话状态。
#[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() 留作兜底。
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,另一个实例,用的是同一份代码。找到它之后,我在那个实例上重放了一次界面会发的请求。
$ 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 字节。
那一刻我明白前端为什么没反应了。它拿到的是一个成功的响应,一个空的事件流,没有事件就没东西渲染,没有错误就没东西提示。前端一点问题都没有,它只是诚实地展示了一个什么都没有的服务端。
继续挖,根因有两层。第一层很蠢,那个实例的 .env 里 DEEPSEEK_API_KEY 是空的,复制的 .env.example,忘了填。第二层才是真正的问题,模型 401 这个错误没有出现在事件流里,adk-server 把 provider 的异常挡在了 SSE 之外,调用方完全无法区分“模型说我 key 不对”和“模型无话可说”。
修法我选了最直接的那种,启动时先探测一次模型,让配置问题在启动阶段就暴露,而不是等第一个用户来踩。
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 的调用,换来的是下面这两行。
$ 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-server 在 create_app() 内部无条件挂载自己的 /ui 路由,ServerConfig 只暴露一个 backend_url,没有关闭开关。所以只能 fork 之后自己托管。
具体是六步。按官方 v2.2.0 tag 把源码取到项目里,翻译(App.tsx 96 处、api.ts 2 处、index.html 的 lang 和标题),把 vite 产物改成固定文件名,用 include_bytes! 内嵌进二进制,加一层 middleware 短路 /ui/ 下面的四个路径(/ui/、/ui/index.html、/ui/assets/ui.js、/ui/assets/ui.css),头部照抄框架的 CSP 策略。
翻译时我特意做了一件事,只翻译显示文本,内部标识一个不动。running、agent、topology 这些状态值仍然是英文,它们同时也是 CSS 类名和逻辑判断的依据,前面加三个映射常量负责显示,逻辑零改动。还有一步我认为特别值,保真校验。fork 别人的代码最怕的就是“我这到底是不是它真正在用的那份”,所以我用 npm ci 锁死工具链,把构建产物的 md5 和 crates.io 上 adk-server 2.2.0 里内嵌的 bundle 对比。
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>。
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 只编依赖,之后再换真实源码。
# 依赖层:先只放清单 + 占位 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 组合这三样,得有心理准备。
如果重来一次
- 先写启动探测(十行代码),再写业务。这一条能省掉我那次最难受的排查。
- 先用浏览器 F12 看一遍内置界面到底打哪个 endpoint、body 长什么样,再动手写客户端,比读文档快一个数量级。
- 先把
parameters_schema()打印出来核对,那才是模型看到的契约。 - 会话后端一开始就用 SQLite。内存后端只适合 CI,“重启丢数据”会掩盖很多问题,包括你以为已经写完的功能。
- 容器化当天就把 SIGTERM 处理掉,否则每次发布都要等满超时。
- 给“空响应”加监控。一次请求产出 0 个事件,应该被当成异常。
最后
整个项目最后是这个样子,src/ 三个文件、543 行代码,换来一个自带中文界面、会话可持久化、能塞进容器、单文件部署的 agent 服务。Rust 写 agent 现在确实不像 Python 那么“伸手就有”,但能塞进现有服务、一个二进制跑起来、没有额外运行时,这件事是别的方案不太容易给的。adk-rust 有它的粗糙处,上面那些坑都是真的,用下来的整体感受是它在往对的方向长。
代码和配图都在我本地的 adk-agent-service/ 目录里,想复现的话按文中的命令走一遍就行。有问题欢迎评论区聊,尤其如果你也踩过“200 但什么都没发生”这种坑,我很想知道你当时是怎么定位的。
(参考 adk-rust 仓库 · crates.io · docs.rs )