03. 工具设计基础
工具是 Agent 接触外部世界的唯一通道。工具设计不好,模型就会学到错误行为;工具输出不稳定,loop 就难以测试;工具权限过大,安全边界会变成装饰。一个可用的 Coding Agent,工具层要同时服务模型、用户界面、日志和安全策略。
工具不是函数包装器
把本地函数直接暴露给模型通常会失败。普通函数的参数是给程序员看的,错误信息也是给程序员看的;Agent 工具的参数和错误要给模型消费。模型不擅长从堆栈里推断“下一步该怎么改”,但很擅长根据结构化、明确、短小的反馈修正调用。
一个工具定义应该回答四个问题:
- 什么时候使用这个工具。
- 参数如何表达。
- 输出会包含什么,可能被怎样截断。
- 失败时模型可以怎么修正。
例如 grep 的描述不要只写“搜索文本”。更好的描述是:在不知道文件位置时搜索工作区;查询应尽量具体;结果最多返回前 100 条,单行超长会被截断;如果结果太多,请缩小关键词或限定目录。描述里的每个数字都必须和运行时真实行为一致——描述说 50 条、实现返回 100 条,模型就会基于错误的预期规划下一步。
参数校验
模型输出是 unknown。即使 provider 宣称会按 schema 返回参数,运行时也必须重新校验。因为流式 tool args 可能被截断,模型可能多给字段,旧会话恢复时可能带着旧 schema 的参数。
教学项目可以先手写校验:
type ReadInput = {
path: string;
};
function parseReadInput(value: unknown): { ok: true; input: ReadInput } | { ok: false; message: string } {
if (typeof value !== "object" || value === null) {
return { ok: false, message: "Expected an object with a path field." };
}
const record = value as Record<string, unknown>;
if (typeof record.path !== "string" || record.path.length === 0) {
return { ok: false, message: "Expected path to be a non-empty string." };
}
return { ok: true, input: { path: record.path } };
}
生产系统可以使用 JSON Schema、Zod 或 Valibot,但原则一样:校验失败要产生 tool result,而不是让运行时崩溃。错误消息要可行动,例如“path 必须是相对路径”,而不是“validation failed”。
校验还有一个反直觉的经验:不要过度严格。真实模型经常在参数里多塞一两个 schema 之外的字段(比如给 edit 的每条替换多加一个 description)。如果你的校验器对未知字段一律硬拒绝,会制造大量本可避免的失败回合——模型重试一次就是一次真实的 token 开销。成熟系统的做法是在校验前加一个参数规整步骤:剥离无害的多余字段、做类型宽容转换(数字字符串转数字),只对真正影响语义的问题报错。宽容输入,严格输出。
输出为模型而写
工具输出有两类消费者:模型和人。模型需要简洁、稳定、可继续推理的文本。人可能需要完整 diff、命令退出码、执行耗时、截断策略和可展开详情。不要把人类 UI 需要的所有结构塞进 tool result 文本里。
建议每个工具返回两层结果:
type ToolResult<Details> = {
message: ToolResultMessage;
details: Details;
};
message 进入 LLM 上下文,details 进入事件流、日志或 UI。edit 工具可以给模型一句“替换成功,修改了 2 行”,同时给 UI 一个结构化 diff。截断信息也属于 details:UI 可以显示“输出被截断到 50KB”,并提供展开完整输出的入口,而模型只需要知道截断发生了、如何取回更多。这样既省 token,又不会牺牲可观察性。
只读工具箱
在给 Agent 写权限之前,先实现只读工具箱:
read: 读取一个文本文件,支持行偏移和行数限制,超长时从头部截断。ls: 列出目录,区分文件、目录、符号链接和隐藏项。grep: 搜索文本,限制结果数量,返回匹配行和路径。find: 按名称查找文件,限制遍历范围和返回数量。
几个来自真实系统的实现细节值得从第一版就做对。read 的截断上限可以取 2000 行或 50KB(先到为准),并处理一个边角情况:如果第一行就超过字节上限,要返回明确说明而不是空字符串。grep 和 ls 应该尊重 .gitignore,否则模型会在 node_modules 里搜出几百条噪声;grep 直接委托给 ripgrep 这类成熟工具,比自己写遍历更快也更正确。find 同样要有默认结果上限(一个常见值是约 1000 条),超出就明确告诉模型收窄条件,而不是静默截断;它还应该接受 glob 模式,并和 grep 共享同一套忽略规则——.gitignore 命中的路径、.git 目录和常见构建产物默认都排除在遍历之外。搜索工具真正的价值是高信噪比的定位,而不是把仓库里每个文件都倒给模型。read 还要剥离 BOM——那个不可见的 UTF-8 标记如果混进上下文,模型后续做精确替换时会永远匹配不上。
只读工具的目标不是功能全面,而是让模型建立“先观察再行动”的习惯。system prompt 也应该明确要求:编辑前必须读取目标文件;不知道路径时先用搜索;不要猜测文件内容。
运行观察
一个好的 read 结果应该像这样:
Read src/config.ts lines 1-42.
Output was truncated after 200 lines. Request a narrower range if needed.
1 export type Config = {
2 model: string;
3 maxTurns: number;
...
这里同时给了模型事实、边界和下一步建议。坏输出则是“文件太长”或直接贴满几万行。前者无法继续,后者浪费上下文。
生产化取舍
工具层至少要考虑这些策略:
- 路径必须先解析到工作区边界内,不能让
..逃逸;符号链接要解析到真实路径再做判断。 - 文本读取要处理编码和二进制文件:检测到二进制就拒绝读取,说明文件类型,而不是把乱码塞进上下文。
- 长输出要截断,并明确告诉模型截断发生了、按什么维度截断(行数还是字节)。
- 命令类工具要有超时和进程树清理——只杀直接子进程,孙进程会变成孤儿继续占用端口。
- 写文件工具要参与同一文件的写队列,避免并行覆盖。
- 工具结果要带稳定 id,方便 UI 和日志关联。
- 每个工具接收 AbortSignal,长操作要在合理的检查点响应取消。
还有一个值得提前铺设的接口:把文件读写和命令执行抽象成 operations 对象(readText、writeText、exec 等),工具通过它访问世界,而不是直接 import node:fs。本地实现只是其中一种;日后要支持 SSH 远程工作区、容器沙箱或测试用内存文件系统时,换一个 operations 实现即可,所有工具逻辑原封不动。成熟系统正是靠这一层让同一套工具同时跑在本机和远程环境上。
最后是执行模式。有些工具天然不能并行——两个 bash 同时跑会互相干扰输出和 cwd。工具定义里可以声明执行模式(并行安全或必须串行),由第六章的执行器统一调度,而不是每个工具自己加锁。
这些策略听起来像细节,但它们决定 Agent 是“偶尔能演示”还是“能在真实仓库里使用”。
练习
完成 read、ls、grep 三个只读工具。
验收标准:
- 对不存在路径返回
isError: true,错误里包含可修正建议。 - 对超长文件截断,并说明截断策略和取回更多内容的方法。
- 对二进制文件拒绝读取,而不是把乱码塞进上下文。
- 所有路径都必须解析在工作区内,符号链接被解析后再判断。
- 参数带多余字段时不直接失败,规整后继续执行。
- 用 faux provider 让模型先
grep后read,验证两次工具调用能串起来。