工具系统的抽象层次:LLM 的"用户界面"设计
这是《写一个 Coding Agent Harness and TUI 》系列的第 2 篇。 上一篇我们建立了元问题:确定性代码如何与概率性 LLM 安全地对齐。 这一篇是这个元问题的第二个投影——工具接口层。
问你一个问题:你写的工具,使用者是谁?
如果你的第一反应是"我自己的代码调用它"——停。重新想。在 coding agent 里,工具的调用者是 LLM——一段自然语言推理引擎,它不读你的代码,不查你的类型签名,不知道你的 enum 有哪些变体。
LLM 了解你的工具的唯一途径是你写的 description() 方法返回的那段话。 LLM 理解工具参数的唯一途径是你写在 JSON Schema 里的 properties 和 required 字段。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 描述。
通道宽度决定了工具设计的全部约束
窄通道意味着:
每一个你可能以为 LLM “应该知道"的约定,都需要显式写在 description 里。 “这个工具只能读文本文件,不能读二进制”——你不写,LLM 就可能传一个
.png路径。每一个参数的类型、默认值、枚举取值范围,都需要在 JSON Schema 里精确声明。
timeout的单位是秒还是毫秒?你不写,LLM 可能传30当成 30 毫秒,但它要的是 30 秒。每一个错误返回都必须包含"为什么出错"和"怎么修正”。 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,包含 name、description、parameters(JSON Schema)。它由 trait 的 to_definition() 方法生成。
传统解释:工具的内部表示和发给 LLM 的外部格式解耦。改了 JSON Schema 格式(比如从 OpenAI 格式迁移到 Anthropic 格式),不影响 trait 方法签名。
但这只解释了"数据格式解耦",没解释"作者解耦"。
工具代码的作者和工具描述的迭代者可能是两个人
写 BashTool 的人需要懂 std::process::Command、wait_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 Vec | O(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 工具系统的抽象层次 ← 你在这里 3 Agent 循环的控制流决策 4 文件编辑的核心设计 5 TUI 的三个架构决策 6 前后端分离的架构决策 7 安全设计的基础决策 8 扩展系统的设计哲学

