工具系统的抽象层次:LLM 的"用户界面"设计

这是《写一个 Coding Agent Harness and TUI 》系列的第 2 篇。 上一篇我们建立了元问题:确定性代码如何与概率性 LLM 安全地对齐。 这一篇是这个元问题的第二个投影——工具接口层。


问你一个问题:你写的工具,使用者是谁?

如果你的第一反应是"我自己的代码调用它"——停。重新想。在 coding agent 里,工具的调用者是 LLM——一段自然语言推理引擎,它不读你的代码,不查你的类型签名,不知道你的 enum 有哪些变体。

LLM 了解你的工具的唯一途径是你写的 description() 方法返回的那段话。 LLM 理解工具参数的唯一途径是你写在 JSON Schema 里的 propertiesrequired 字段。LLM 知道工具出错后该怎么做的唯一途径是你返回的错误消息。

你设计的不是 Rust API——你设计的是 LLM 的用户界面。上一篇讲的"确定性代码与概率系统之间的接口",在工具层面的具体形态就是这个:代码能承受任何输入,但 LLM 理解工具的唯一信息通道是自然语言和 JSON Schema。这两个通道的宽度和精度,决定了 agent 能用好工具的概率上限。

〇、元问题:信息通道的宽度

传统 API 的信息通道

你设计一个 Rust 库给别人用时,信息通道的宽度接近无穷:

调用方看到:
├── 函数签名(参数类型、返回类型、生命周期)
├── 文档注释(/// ...)
├── 编译期检查(类型错误编译不过)
├── IDE 提示(自动补全、参数说明)
└── 源码(想看细节随时跳转)

类型系统是免费文档。 fn read_file(path: &Path) -> io::Result<String> ——调用方不需要读文档就知道参数是什么、返回什么、可能失败。

LLM 的信息通道

LLM 调用你的工具时,信息通道窄到只剩两样东西:

LLM 看到:
├── 工具的 description(一段自然语言文本)
└── JSON Schema 的 parameters(属性名、类型、是否必选)

没有类型系统。没有编译期检查。没有 IDE。没有源码。只有一段文本和一个 JSON 描述。

通道宽度决定了工具设计的全部约束

窄通道意味着:

  1. 每一个你可能以为 LLM “应该知道"的约定,都需要显式写在 description 里。 “这个工具只能读文本文件,不能读二进制”——你不写,LLM 就可能传一个 .png 路径。

  2. 每一个参数的类型、默认值、枚举取值范围,都需要在 JSON Schema 里精确声明。 timeout 的单位是秒还是毫秒?你不写,LLM 可能传 30 当成 30 毫秒,但它要的是 30 秒。

  3. 每一个错误返回都必须包含"为什么出错"和"怎么修正”。 LLM 没有堆栈追踪,没有 debugger。它只能从你的错误消息里推断下一步该做什么。

通道宽度 = agent 工具调用成功率的上限。 这个上限不由 Rust 代码质量决定——由你在 description() 和 JSON Schema 里花了多少心思决定。

grok-build 的生产经验:工具 description 的迭代次数远超工具代码本身的迭代次数。一个工具的代码可能 50 行,写完就不动了。但它的 description 可能在 agent 跑了 100 次之后还在改——每改一次,agent 的正确调用率上升几个百分点。

一、trait vs enum——这不是类型系统问题,是"信息通道是否可扩展"

问题

// 方案 A:enum
enum Tool {
    ReadFile(ReadFileImpl),
    Bash(BashImpl),
    // 加新工具 = 改这个 enum + 改所有 match
}

// 方案 B:trait
trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn to_definition(&self) -> ToolDefinition;
    fn call(&self, input: ToolInput) -> Result<ToolOutput, String>;
}
// 加新工具 = impl Tool + 注册

这个选择在传统软件开发里的分析

enum 对修改封闭——加一个工具类型要改 enum 定义 + 所有 match 分支。trait 对扩展开放——加一个工具类型不影响已有代码。标准的"开放-封闭原则"。

但这个分析不够。它没回答:为什么对 agent 工具系统而言,“对扩展开放"不是一个 nice-to-have,而是一个生存必需?

在 agent 语境下的真实理由:工具的演化速度

一个传统软件的 API 集合,加减频率可能以月或年为单位。一个 agent 的工具集合,加减频率可能以天为单位。

你今天发现 agent 在处理大文件时经常卡住——加一个 read_file_chunked 工具。明天发现 agent 不理解某个项目的目录结构——加一个 project_layout 工具。下周接入一个 MCP server——一下子多出来 5 个外部工具。

如果工具集合的理解是"我们拥有了所有需要的工具”,工具系统的设计目标是"稳定"。如果工具集合的理解是"我们永远在发现新工具,而且外部可能注入我们不知道的工具",工具系统的设计目标是"可扩展"。

enum 适合前者。trait 适合后者。

agent 的工具系统永远处于后者。

grok-build 有 30+ 个内置工具,通过 MCP 协议还能动态加载外部工具。它如果用了 enum,任何外部工具的接入都需要修改核心代码并重新编译。

二、Arc vs Box vs Rc——这不是所有权问题,是"工具是否可以被共享观察"

Box 的单一所有权:谁持有 Box,谁独占工具。Rc 的非线程安全:不能跨线程共享。Arc 的共享所有权 + 线程安全:多个组件可以同时持有工具引用。

传统分析止步于"因为我们选 Send + Sync 所以 Rc 不能用"。但更深的问题是:为什么需要多个组件共享工具?

因为 agent 的信息通道不是点到点的

┌─────────────────┐     ┌───────────────┐     ┌──────────────┐
│   Turn Loop      │────→│ ToolRegistry  │←────│   TUI        │
│ (需要调用工具)   │     │ (持有 Arc<T>) │     │ (列出工具)   │
└─────────────────┘     └───────────────┘     └──────────────┘
                         ┌───────┴───────┐
                         │   MCP Server  │
                         │ (动态注册工具) │
                         └───────────────┘

三个组件需要看到同一份工具:Turn Loop 调用它们,TUI 展示可用工具列表,MCP Server 动态注册新工具。如果用 Box,只有一个组件能拥有工具——其他两个组件需要间接访问(通过 Registry 的 get 方法,但 get 返回的引用生命周期受 Registry 的可变借用限制)。

Arc 不只是在解决"线程安全"——它是在解决"信息通道的拓扑结构"。

工具信息不是从 Registry 单向流向 Turn Loop。它是多个组件共享一个可增长的工具池。Arc 把"我的工具"变成了"大家的工具"——语义上更准确。

三、ToolInput 为什么用 enum 而不是 serde_json::Value——这不是类型安全 vs 灵活性

类型安全上的对比很清楚:enum 编译期检查,Value 运行时 panic。enum 的参数字段有类型(path: String),Value 里是 Value::String("..."),需要手动 as_str()

但 agent 语境下有一个更深的原因。

LLM 产生的参数是 JSON 字符串——它天然是 Value 形态

LLM 调用工具时,传的参数是 function.arguments = '{"command": "ls", "timeout": 30}'——一段 JSON 字符串。你用 serde_json::from_str 把它解析成 Rust 类型。这个过程可能在两种地方出错:

方案 A(Value→手工提取):

let args: Value = serde_json::from_str(&tc.function.arguments)?;
let command = args["command"].as_str().ok_or("missing command")?;
let timeout = args["timeout"].as_u64();

错误发生位置:散落在 as_str()as_u64() 等手工提取调用里。每次提取都是一个潜在的 panic 点。错误信息你手写。

方案 B(enum→serde 自动反序列化):

let input: ToolInput = serde_json::from_str(&tc.function.arguments)?;

错误发生位置:集中在 serde_json::from_str 调用的那一刻。错误信息 serde 自动生成(“missing field command” 或 “invalid type: string ‘abc’, expected u64”)。

这对 agent 意味着什么

serde_json::from_str 返回错误时,你拿到的是一个完整的、结构化的错误描述,可以直接传回给 LLM:"工具 bash 的参数解析失败:字段 'command' 是必选的,但未提供。"

LLM 读到这条消息,下一轮就会补上 command 字段。类型错误变成了 LLM 的纠错信号。

手工提取的错误消息质量依赖你手写的 ok_or("...") 的信息量。如果你只写了 ok_or("error")——LLM 不知道为什么出错,它会重试同样的参数——你的 agent 就死循环了。

enum 不只是类型安全——它把解析阶段的错误消息自动化了,而错误消息的质量直接决定了 agent 的自我纠正能力。

四、ToolDefinition 为什么独立于 trait——不是"关注点分离"

ToolDefinition 是一个独立 struct,包含 namedescriptionparameters(JSON Schema)。它由 trait 的 to_definition() 方法生成。

传统解释:工具的内部表示和发给 LLM 的外部格式解耦。改了 JSON Schema 格式(比如从 OpenAI 格式迁移到 Anthropic 格式),不影响 trait 方法签名。

但这只解释了"数据格式解耦",没解释"作者解耦"。

工具代码的作者和工具描述的迭代者可能是两个人

写 BashTool 的人需要懂 std::process::Commandwait_with_output、跨平台 shell 差异。写 BashTool 的 description 的人需要懂 prompt engineering——知道怎么写描述能让 LLM 正确选择 bash 而不是 read_file,知道怎么描述 timeout 参数让 LLM 不传 300 当成 300 秒。

工具实现 = Rust 工程。工具描述 = prompt engineering。这两种技能很少集中在同一个人身上。

to_definition() 把这两个关注点分开——工具的 Rust 代码可以很稳定,工具的 JSON Schema 和 description 可以持续迭代,不需要动 trait 签名。在这个意义上,它不只是关注点分离——它是团队分工的接口

五、代码

~120 行,tutorial-code/part-02-tools/

核心结构:

trait Tool: Send + Sync {                     // 可扩展性
    fn to_definition(&self) -> ToolDefinition; // 信息通道内容
    fn call(&self, input: ToolInput) -> Result<ToolOutput, String>; // 错误消息=纠错信号
}
struct ToolRegistry(HashMap<String, Arc<dyn Tool>>); // 共享引用
enum ToolInput { ReadFile { path: String }, Bash { command: String, timeout: Option<u64> } }

验证:

cargo run
# === 已注册 2 个工具 ===
#   • read_file — 读取文件内容...
#   • bash — 执行 shell 命令...

六、总结:五个设计决策归约到一个根本问题

决策传统软件视角Agent 视角——根本问题是
trait vs enum开放-封闭原则工具集合是否永远在增长?→ 必须是
Arc vs Box/Rc所有权 + 线程安全信息通道是点到点还是多对多?→ 多对多
enum vs Value类型安全 vs 灵活性解析错误消息能不能让 LLM 自己修正?→ enum 的自动错误消息更好
独立 ToolDefinition数据格式解耦工具代码作者和 prompt 迭代者是不是同一个人?→ 通常不是
HashMap vs VecO(1) vs O(n)Turn Loop 里每小时多少次查找?→ 每次 tool_call 都查

通道宽度决定了你的设计上限。 下一篇把工具和 LLM 接通——Turn Loop。不只是循环调用,而是"agent 如何在不确定的环境中做决策"的认知模型:Observe→Reason→Act→Observe 循环的工程实现,以及它为什么是 agent 和传统程序最根本的分界线。

完整代码在 tutorial-code/part-02-tools/


📮 每周一篇 Rust / AI Agent 深度教程。

微信扫码关注「全栈之巅 - 梦兽编程」,获取:

  • 🔬 Agent 架构设计深度分析
  • 🦀 Rust 工程实践与性能调优
  • 💬 加入学习社群,与作者直接交流


这是《写一个 Coding Agent Harness and TUI》系列第 2 篇。 ← 返回系列总目录

标题
1确定性代码与概率系统的接口设计
2工具系统的抽象层次 ← 你在这里
3Agent 循环的控制流决策
4文件编辑的核心设计
5TUI 的三个架构决策
6前后端分离的架构决策
7安全设计的基础决策
8扩展系统的设计哲学

下一篇:Agent 循环——不只是 while,是 agent 如何在不确定环境中做决策