从 match 到 trait:工具数量变多之后的必然选择

Part 1 的 Agent 循环只有两个工具,一个 match 语句足够:

match name {
    "read_file" => read_file(args).await,
    "list_files" => list_files(args).await,
    _ => Err(...),
}

两个工具只占两个分支。三个工具开始拥挤。六个工具时,分支里塞满了克隆、重试、错误格式化、参数解析,几乎一模一样却很难复用。Agent 循环本身没变,但循环周围的空间越来越复杂。

问题不在模型,不在提示词,在分发器。每新增一个工具,你要改三个地方:

  1. match 里加一条分支;
  2. 把 JSON 参数解析成正确的结构体;
  3. 把结果格式化成模型能读的字符串。

而这三件事,对每个工具来说几乎相同。Part 3 把这三件事抽象成一个 trait。Eugene v0.3 引入 SkillRegistry、自动 JSON Schema、并行调用、错误分类和重试。循环本身不变,但一切围绕循环获得了一个名字和一份契约。

用 Rust 构建 AI Agent 的 Skill Registry 架构:trait、注册表、并行分发


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_onlyis_concurrency_safe 告诉 Agent 能不能并行执行。SkillError 是后面要说的错误分类。

关联类型 Input 是这里的关键。它让每个实现者携带自己的输入形状,而不需要运行时类型标签。ReadFileInputReadFileArgsListFilesInputListFilesArgsrun 直接收到类型化结构体,函数体内无需手动 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 类型中模型不需要的元信息(比如 serdedeny_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_filesread_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 源码在每个工具调用前后跑 executePreToolHooksexecutePostToolHooks,用来做权限门、用户 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_filecanonicalize 再检查是否仍在沙箱内;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 可以直接插进去。


相关代码与参考


觉得有帮助?

如果这篇内容对你有用,欢迎:

  • 点赞 / 转发 / 收藏,让更多 Rust + AI 开发者看到;
  • 关注公众号「全栈之巅-梦兽编程」,每周更新 Rust / AI 工程实践;
  • 在评论区留下你的问题,关于 Skill 设计、并发安全或权限门都可以聊;
  • 了解 AI 编程助手服务,用 Claude Code 级能力提升你的团队效率。