用 Rust 从零构建 AI Agent(三):用 Trait 重构 Skill Registry

从 match 到 trait:工具数量变多之后的必然选择
Part 1 的 Agent 循环只有两个工具,一个 match 语句足够:
match name {
"read_file" => read_file(args).await,
"list_files" => list_files(args).await,
_ => Err(...),
}
两个工具只占两个分支。三个工具开始拥挤。六个工具时,分支里塞满了克隆、重试、错误格式化、参数解析,几乎一模一样却很难复用。Agent 循环本身没变,但循环周围的空间越来越复杂。
问题不在模型,不在提示词,在分发器。每新增一个工具,你要改三个地方:
- 在
match里加一条分支; - 把 JSON 参数解析成正确的结构体;
- 把结果格式化成模型能读的字符串。
而这三件事,对每个工具来说几乎相同。Part 3 把这三件事抽象成一个 trait。Eugene v0.3 引入 Skill、Registry、自动 JSON Schema、并行调用、错误分类和重试。循环本身不变,但一切围绕循环获得了一个名字和一份契约。

Skill 的本质:一个带类型的工具
Claude Code 源码中每个工具都是一个独立文件夹:name constant、input schema、description fragment、typed entry point,再加上一些元数据标志(是否只读、是否可并行、是否需要用户确认)。这个形状在 Rust 里很自然地映射为一个 trait:
#[async_trait]
trait Skill: Send + Sync + 'static {
type Input: DeserializeOwned + JsonSchema + Send + 'static;
fn name(&self) -> &str;
fn description(&self) -> &str;
fn is_read_only(&self) -> bool { true }
fn is_concurrency_safe(&self) -> bool { self.is_read_only() }
async fn run(&self, input: Self::Input) -> Result<String, SkillError>;
}
run 的返回值是模型会看到的字符串。is_read_only 和 is_concurrency_safe 告诉 Agent 能不能并行执行。SkillError 是后面要说的错误分类。
关联类型 Input 是这里的关键。它让每个实现者携带自己的输入形状,而不需要运行时类型标签。ReadFile 的 Input 是 ReadFileArgs,ListFiles 的 Input 是 ListFilesArgs,run 直接收到类型化结构体,函数体内无需手动 JSON 解析。
#[derive(Deserialize, JsonSchema)]
struct ReadFileArgs {
/// 文件路径,相对于项目根目录。
path: String,
}
struct ReadFile;
#[async_trait]
impl Skill for ReadFile {
type Input = ReadFileArgs;
fn name(&self) -> &str { "read_file" }
fn description(&self) -> &str {
"Read a UTF-8 text file from the current project."
}
async fn run(&self, input: ReadFileArgs) -> Result<String, SkillError> {
// 实际读取 + 沙箱校验
}
}
字段上的 /// 文档注释会被 schemars 抓进生成的 JSON Schema,模型和读代码的工程师看到同一份描述。path 重命名为 file_path 只需改结构体,下轮请求模型自然看到新名字。
从 struct 自动生成 JSON Schema
schemars 在 skill 注册时推导一次,结果缓存。调用方式只有一行:
let schema = schemars::schema_for!(S::Input);
对 ReadFileArgs 生成:
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径,相对于项目根目录。"
}
},
"required": ["path"]
}
schemars 会裁剪掉 Rust 类型中模型不需要的元信息(比如 serde 的 deny_unknown_fields 这类结构),产出紧凑、精确的 schema。这是生产 Agent 的常用做法:不要让模型在字段含义上猜测。
Registry:一个注册表,而不是一堆 match
Registry 拥有一张 Vec<Box<dyn DynSkill>>,按 name 索引。DynSkill 是对象安全的 trait,通过 blanket impl 把任意 Skill 转成 DynSkill:
#[async_trait]
trait DynSkill: Send + Sync {
fn definition(&self) -> ToolDefinition;
fn is_concurrency_safe(&self) -> bool;
async fn run(&self, args: Value) -> Result<String, SkillError>;
}
#[async_trait]
impl<S: Skill> DynSkill for S {
fn definition(&self) -> ToolDefinition {
let schema = schema_for!(S::Input);
ToolDefinition {
name: self.name().to_string(),
description: self.description().to_string(),
input_schema: schema,
}
}
fn is_concurrency_safe(&self) -> bool {
self.is_concurrency_safe()
}
async fn run(&self, args: Value) -> Result<String, SkillError> {
let input: S::Input = serde_json::from_value(args)?;
self.run(input).await
}
}
match 消失。原来每个分支都在干的解析、调用、格式化,被 trait 完全吸收。新增工具只需 registry.add(NewTool),其余自动发生。
并行调用:免费的多路复用
Anthropic API 允许一次 assistant turn 返回多个 tool_use 块。Claude Code 的系统提示里明确写着「在可能的情况下最大化并行工具调用以提高效率」。你问 Eugene “src 文件夹里有什么?Cargo.toml 用的是什么 edition?",模型可能同时发出 list_files 和 read_file("Cargo.toml")。
串行执行会浪费墙钟时间。Registry 的 run_many 只多几行:
async fn run_many(
&self,
calls: &[(String, String, Value)], // (id, name, args)
) -> Vec<(String, Result<String, SkillError>)> {
let mut parallel = Vec::new();
let mut serial = Vec::new();
for (id, name, args) in calls {
match self.index.get(name) {
Some(&i) if self.skills[i].is_concurrency_safe() => {
parallel.push(self.run_one(id.clone(), i, args.clone()));
}
Some(&i) => {
serial.push(self.run_one(id.clone(), i, args.clone()));
}
None => { /* 未知工具 */ }
}
}
let mut results = join_all(parallel).await;
for fut in serial {
results.push(fut.await);
}
results
}
is_concurrency_safe 默认等于 is_read_only,所以只读工具自动并行。需要串行的工具(写操作、没有内部锁的有状态客户端)覆盖该标志即可。两个只读工具节省半秒,十个节省六秒。模型不知道这些,它只觉得 Agent 变快了。
错误:要让模型能恢复
Skill 会失败。网络断开、路径不存在、沙箱拒绝越界访问。每种失败需要不同的下一步。SkillError 分四类:
#[derive(Debug, Error)]
enum SkillError {
#[error("invalid arguments: {0}")]
BadArgs(#[from] serde_json::Error),
#[error("safety check failed: {0}")]
Safety(String),
#[error("transient failure (worth retrying): {0}")]
Transient(String),
#[error("tool failed: {0}")]
Tool(String),
}
| 错误 | 责任方 | 下一步 |
|---|---|---|
BadArgs | 模型 | 作为 tool_result 回传,让模型修正 |
Safety | 模型/用户 | 拒绝执行,把原因告诉用户 |
Transient | 世界 | 指数退避重试 |
Tool | 工具本身 | 作为 is_error: true 的 tool_result 返回,模型决定换工具 |
这就是 Result Architecture:不要把原始字符串扔回给模型,返回一个系统能推理的错误。Rust 的类型系统免费给你这个能力,只要愿意用它的 variant。
重试:只重试值得重试的
Transient 错误需要一个轻量 helper。三次尝试,指数退避:
async fn with_retry<F, Fut, T>(policy: RetryPolicy, mut op: F) -> Result<T, SkillError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, SkillError>>,
{
let mut delay = policy.initial_delay;
for attempt in 0..policy.max_attempts {
match op().await {
Ok(v) => return Ok(v),
Err(e) if e.is_transient() && attempt + 1 < policy.max_attempts => {
tokio::time::sleep(delay).await;
delay = delay.mul_f64(policy.backoff);
}
Err(e) => return Err(e),
}
}
unreachable!()
}
429 Too Many Requests 是 transient。400 Bad Request 不是。连接重置是 transient。5xx 服务器错误也是 transient。其余立即短路,因为重试一次坏调用只会浪费时间和预算。
SkillHook:横切逻辑不该住在 Skill 里
把重试逻辑加到十个 skill 里就是重复。权限校验、日志、参数改写、结果脱敏、缓存也类似。Claude Code 源码在每个工具调用前后跑 executePreToolHooks 和 executePostToolHooks,用来做权限门、用户 shell hook、埋点。
eugene-skills 把同样模式抽象为 SkillHook:
#[async_trait]
pub trait SkillHook: Send + Sync + 'static {
async fn before(
&self,
skill_name: &str,
metadata: SkillMetadata,
args: &Value,
) -> Result<HookOutcome, SkillError> { Ok(HookOutcome::Proceed) }
async fn after(
&self,
skill_name: &str,
args: &Value,
result: Result<String, SkillError>,
) -> Result<String, SkillError> { result }
}
enum HookOutcome {
Proceed,
Modify(Value),
Deny(String),
Replace(String),
}
Proceed继续执行;Modify改写参数(用于脱敏或补充上下文);Deny直接拒绝,模型收到is_error和原因;Replace跳过调用,返回缓存结果。
Post-hook 在结果出来后执行,可以记录日志、脱敏路径、或者给 JSON 包一层模型更友好的导语。
let mut registry = Registry::new();
registry
.add(ListFiles)
.add(ReadFile)
.add(DeleteFile)
.with_hook(PermissionGate::new(|_, md: SkillMetadata| !md.is_destructive));
PermissionGate 是内置 hook。它拒绝任何破坏性工具的调用,除非 closure 返回 true。这对应 Claude Code 的 CanUseToolFn。实际实现会配合 PermissionMode 切换「每次询问」和「已预批准」两种模式,无需改动任何 skill。
工具多了怎么办:延迟加载与别名
三五个工具几乎不占 token。三十个工具时,每个工具都带着名字、描述和完整 JSON Schema 进入每次请求,模型会被不需要的工具分散注意力,账单也水涨船高。
Claude Code 的解法是 ToolSearch:大多数工具标记为 shouldDefer,初始 prompt 里不暴露 schema,模型需要调用 ToolSearch 并给出关键词,才会返回相关工具的 schema。每个工具带一个 searchHint(三到十个词,不要句号),让关键词匹配命中同义词而不是只匹配工具名。
eugene-skills 在 trait 上提供同样的开关:
fn search_hint(&self) -> Option<&str> { None }
fn should_defer(&self) -> bool { false }
fn aliases(&self) -> &[&'static str] { &[] }
启用示例:
impl Skill for DeleteFile {
type Input = DeleteFileArgs;
fn name(&self) -> &str { "delete_file" }
fn description(&self) -> &str { "Delete a file from the project." }
fn should_defer(&self) -> bool { true }
fn search_hint(&self) -> Option<&str> { Some("remove file rm" ) }
fn aliases(&self) -> &[&'static str] { &["remove_file"] }
// ...
}
should_defer 在工具数量跨过阈值时启用。aliases 在重命名工具时保留旧名,模型无需知道破坏性变更。
三条规则:Atomic、Result、Safety
源码把好的 Skill 总结成三条规则,它们可以直接套用到 Rust。
Atomic:一个 Skill 只做一件事。read_file 读文件,list_files 列表,没有 search_and_summarize。组合应该发生在 Agent 循环里,让模型在中间结果处介入。如果你的 Skill 名字里带 “and”,请拆分它。
Result:Skill 返回系统能推理的结果。在 Rust 里就是 Result<String, SkillError>。错误 variant 区分失败类型,富返回类型(返回 JSON 编码的结构体,或者更高级地替换为自定义 Output 类型)让下游能提取字段而不是解析自由文本。
Safety:Skill 假设输入是敌意的。read_file 先 canonicalize 再检查是否仍在沙箱内;web_fetch 校验 URL scheme,拒绝 file://,限制域名白名单。模型不是攻击者,但它训练过人类写过的所有对抗性提示,偶尔会鹦鹉学舌。防御性代码只需几行,却能防止 CVE 级别的麻烦。
Eugene v0.3 实战:循环没变,分发变了
Agent 循环还是 Part 1 的样子,唯一变化是分发走 Registry:
let mut registry = Registry::new();
registry.add(ListFiles).add(ReadFile);
let tools: Vec<Tool> = registry
.definitions()
.into_iter()
.map(Into::into)
.collect();
// ... 构建系统提示词 ...
for _ in 0..MAX_TURNS {
let response = with_retry(policy, || async {
send(&http, &api_key, &system_blocks, &tools, &messages).await
}).await?;
// 把 assistant 消息追加到 messages
// 如果包含 tool_use 块,调用 registry.run_many,得到 tool_result
// 如果包含最终回复,返回给用户
}
新增工具只需加一行 .add(...)。循环不变、分发器不变、提示词构建器不变。Schema 从 #[derive(JsonSchema)] 直接流到模型。这就是小抽象选对抽象对象时产生的杠杆。
你问 Eugene “src 里有什么?Cargo.toml 用的是什么 edition?",它会在同一轮发出两个调用,Registry 并行分发,模型再综合两个结果作答。两次文件读取,一次不阻塞的 Claude 往返。
声明式 Skill:让非工程师也能扩展 Agent
原文还提到一种更灵活的形式:声明式 Skill。Markdown 文件描述一个工具和一段提示词,启动时加载,对模型来说就像普通代码工具一样。Claude Code 的 SkillTool 就是这种模式:用户用 .md 文件定义 slash 命令,运行时再展开成完整提示。
Rust 实现也很简单:一个 MarkdownSkill 实现 Skill,接受自由字符串输入,内部做一次子 LLM 调用。Registry 把它当普通 skill 处理。eugene 系列还没内置这个能力,因为真正产生价值的是积累的具体 skill,而不是加载机制。等你有了一批可用 skill,加上声明式加载只是一个晚上的工作量。Part 7 的 MCP server 更高级:skill 跑在另一个进程里,但组合方式相同。
这揭示了什么
Trait 是 skill 作者与 Agent 运行时之间的契约。作者写带类型校验的 run,运行时负责 JSON Schema、分发、并行、重试和错误渲染。双方不需要了解对方的具体关切。这种分离让基于 Registry 的 Agent 能持续增长而不卡死。
关联类型通过 trait 把类型化输入传递下去,让每个 skill 在编译期保持类型检查。Blanket impl 进入 DynSkill 让 Registry 能装异构集合。schemars 填补 Rust struct 与 wire format 之间的鸿沟。async_trait 让这一切异步且无 ceremony。这些都不是 Agent 特有工具,而是 Rust 插件系统的标准工具包。Skill 只是带明确调用者的插件。
下一步:从单循环到状态机
Part 4 将 Agent 带出单个循环。有些任务需要多步:规划阶段、执行阶段、验证阶段、可选的人工审批阶段。能在真实世界生存的形态是状态机。eugene-state 将引入:类型化图运行器、SQLite 检查点(长任务重启后恢复)、以及在任意破坏性步骤前暂停确认。Part 3 写好的 skill 可以直接插进去。
相关代码与参考
eugene/crates/eugene-skills:trait、Registry、重试、Hook 的完整实现,包含 16 个单元测试,覆盖分发、并行执行、schema 生成、重试、元数据传播、别名、关键词搜索、延迟加载和四种 pre-hook 结果。 ⭐ Star on GitHub- Rust 对象安全 trait 与 blanket impl
- schemars:从 serde struct 生成 JSON Schema
- Anthropic tool use / parallel tool_use
觉得有帮助?
如果这篇内容对你有用,欢迎:
- 点赞 / 转发 / 收藏,让更多 Rust + AI 开发者看到;
- 关注公众号「全栈之巅-梦兽编程」,每周更新 Rust / AI 工程实践;
- 在评论区留下你的问题,关于 Skill 设计、并发安全或权限门都可以聊;
- 了解 AI 编程助手服务,用 Claude Code 级能力提升你的团队效率。
