开场:它活了
我至今记得那个瞬间。
上一篇写完的 mini-agent,还只是个 Chatbot:我问一句,它答一句。那天晚上,我给它加了两个工具,读文件和列目录,总共不到两百行代码。然后在一个它从没见过的项目目录里启动它,问:"这个项目是干什么的?"
屏幕上出现了一行青色的字:
⏺ list_files {"path":"."}它先看了一眼目录。然后:
⏺ read_file {"path":"README.md"}它去读 README 了。读完之后,它又读了 go.mod,然后读了 main.go,最后给了我一段介绍,说得八九不离十。
我没有告诉它先读哪个文件,也没有告诉它读几个。 是它自己决定的。我坐在屏幕前,有一种很奇妙的感觉:上一篇写的那个循环,第一次真正转了起来,而且转的方向是它自己选的。
这一篇就是把那两百行代码讲清楚。读完本篇,你应该能回答这几个问题:
- 一个工具由哪两部分组成?
- 工具注册表要负责哪些事?
- 工具调用是怎么被分发和执行的?结果怎么交回去?
read_file和list_files有哪些看起来不起眼、但很重要的设计?- 怎么不让 Agent 读到工作目录以外的文件?这道墙又挡不住什么?
- 有了能跑命令的终端,为什么还要专门的读文件工具?
一、工具 = 一份定义 + 一段代码
1.1 Tool 接口
在 mini-agent 里,一个工具就是实现了这个接口的东西(agent/tools/registry.go):
// Tool 是一个工具。模型只看得到 Definition(),真正执行的是 Run()。
type Tool interface {
Definition() llm.Tool
Run(ctx context.Context, input json.RawMessage) (string, error)
}两个方法,对应工具的两面:
Definition():给模型看的。 名字、描述、参数的 JSON Schema。第 19 篇说过,这是模型对这个工具的全部认知;Run():给程序跑的。 拿到模型给的参数(一段原始的 JSON),干活,返回一段文本结果,或者一个错误。
给模型看
名字、描述、参数 schema"] T --- R["Run()
给程序跑
解析参数、干活、返回文本"] style D fill:#e3f2fd,stroke:#1565c0 style R fill:#fff3e0,stroke:#e65100
注意 Run 返回的是 string,不是某种结构化的数据。因为工具的结果最终要变成 tool_result 交给模型,而模型读的是文本。在工具内部就把结果整理成模型容易读的文本,比返回一个结构体、再在外面序列化成 JSON 要好得多(第 20 篇:返回值要精简、可读)。
1.2 为什么是接口
把工具定义成接口,而不是一个大的 switch name { case "read_file": ... },好处是:
- 每个工具是一个独立的类型,定义和实现放在一起,读代码时不用跳来跳去;
- 加工具不用改循环:后面几篇会陆续加上
edit_file、run_bash、task、load_skill、MCP 工具,每一个都只是"再实现一个 Tool",agent.go一行都不用动; - 外来的工具也能接进来:第 31 篇接入 MCP 时,每个 MCP 工具被包装成一个实现了
Tool接口的remoteTool,对注册表来说它和内置工具没有区别。
1.3 参数:一段原始的 JSON
Run 收到的 input 是一段 json.RawMessage,也就是模型写出来的那段参数原文,原封不动。每个工具自己决定怎么解析它。read_file 的 Run 一开头是这样的:
var in struct {
Path string
Offset, Limit int
}
if err := parse(input, &in); err != nil {
return "", err
}parse 是注册表里的一个小帮手,就做两件事:参数为空时当成 {},解析失败时给出一句模型看得懂的话:"参数不是合法的 JSON 或类型不对:……"。
注意这个结构体的字段没有写 json 标签。Go 的 json.Unmarshal 匹配字段名时不区分大小写,模型传 "path",会自动落进 Path;传 "offset",落进 Offset。少写几个标签,代码更短;而参数的名字和描述,已经在 Definition() 里的 JSON Schema 里写清楚了,那才是模型真正看到的地方。
还有两种参数问题,parse 管不了,要工具自己管:
- 类型对了,值不对:
offset是 -5,path是空字符串。这些要在工具里检查,并给出具体的提示; - 模型多传了参数:比如传了一个 schema 里没有的
encoding。json.Unmarshal会默默忽略它。大部分时候这样就好,模型不会因为多写了一个字段就失败;但你也要知道,它以为自己指定了编码,其实没有生效。
二、工具注册表
2.1 两件事
注册表 Registry 只负责两件事:把定义交给模型,按名字分发调用。
// Registry 是工具注册表:负责"把定义交给模型"和"按名字分发调用"。
type Registry struct {
tools map[string]Tool
// MaxOutput 是单次工具结果的字符上限,超出的部分只保留头尾(第 28 篇)。
MaxOutput int
}2.2 把定义交给模型
// Definitions 按名字排序返回所有工具定义。顺序固定很重要:顺序一变,prompt cache 就失效了(第 37 篇)。
func (r *Registry) Definitions() []llm.Tool {
names := make([]string, 0, len(r.tools))
for n := range r.tools {
names = append(names, n)
}
sort.Strings(names)
// ……按排好的顺序收集每个工具的 Definition()
}为什么要排序?Go 的 map 遍历顺序是随机的。如果不排序,每次请求里工具定义的顺序都可能不一样,而工具定义在请求的最前面(system prompt 附近),顺序一变,整个前缀就变了,缓存全部失效(第 10 篇)。一行 sort.Strings,能省下大量的钱。
这是那种"不知道就会一直亏钱、而且很难发现"的细节。
2.3 按名字分发调用
// Run 执行一次工具调用,永远返回一个 tool_result 块。
//
// 工具出错不是程序出错:把错误信息原样交给模型,它通常能自己换个参数再试。
func (r *Registry) Run(ctx context.Context, call llm.ContentBlock) llm.ContentBlock {
t, ok := r.tools[call.Name]
if !ok {
return llm.ToolResult(call.ID, fmt.Sprintf("没有名为 %q 的工具。可用的工具:%s", call.Name, strings.Join(r.names(), ", ")), true)
}
out, err := t.Run(ctx, call.Input)
out = Truncate(out, r.MaxOutput)
if err != nil {
msg := err.Error()
if out != "" {
msg = out + "\n" + msg
}
return llm.ToolResult(call.ID, msg, true)
}
if out == "" {
out = "(无输出)"
}
return llm.ToolResult(call.ID, out, false)
}几个设计决定,每一个都有原因:
- 永远返回一个
tool_result,永远不 panic,永远不返回 error。 第 02 篇的铁律:每个tool_use都必须有对应的tool_result。不管发生什么,这里都保证交回一个结果; - 工具不存在,也是一个结果。 模型有时会"发明"一个不存在的工具名(比如把
read_file写成readfile),这时返回一个错误结果,并且列出所有可用的工具(第 20 篇:错误信息就是提示词)。模型看到之后,下一次就会用对; - 工具出错,也是一个结果。
Run返回的 error,被包装成is_error: true的tool_result。文件不存在、参数不对,这些都不是程序的 bug,模型看到错误通常能自己纠正; - 出错时,已有的输出也带上。 比如一个命令跑到一半失败了,前面的输出和最后的错误信息都有用;
- 空结果也要说清楚。 返回一个空字符串,模型可能会困惑"是没执行,还是执行了但没有输出?",写成"(无输出)"就明确了;
- 统一截断。 所有工具的输出都在这里经过
Truncate,第 28 篇细讲。
没有这个工具,可用的有:……"] F -->|"存在"| R["t.Run(参数)"] R --> T["截断"] T --> OK{"出错了吗?"} OK -->|"出错"| E2["tool_result(出错)
错误信息"] OK -->|"没有"| S["tool_result
结果文本"] style E1 fill:#fff9c4 style E2 fill:#fff9c4 style S fill:#e8f5e9,stroke:#2e7d32
三、把工具接进循环
上一篇的 Send 里,模型返回 tool_use 时有一行省略号。现在把它补上:
// 3. 模型说"我要用工具",程序替它去执行。所有结果放进同一条 user 消息。
results = a.runTools(ctx, calls)
// 4. 带着结果回到第 1 步。
a.appendUser(results...)runTools 依次执行每一个工具调用(第 30 篇会让只读的工具并发执行),每执行一个,就通知一下 UI:
for i, call := range calls {
a.UI.ToolCall(call.Name, call.Input)
results[i] = a.runTool(ctx, call)
a.UI.ToolResult(call.Name, results[i].Content, results[i].IsError)
}runTool 在真正执行之前,还会先过一道权限检查(第 27 篇),这里先忽略。
终端里看到的那行青色的 ⏺ list_files {"path":"."},就是 UI.ToolCall 打印出来的;下面那行灰色的结果摘要,是 UI.ToolResult 打印的。
循环就是这样转起来的。 第 02 篇的那二十行伪代码,到这里变成了真正能跑的代码。
3.1 它为什么"知道"先看目录、再读 README
回到开场那一幕:没人告诉它先读哪个文件,它却先列目录,再读 README,再读 go.mod 和 main.go,顺序和一个有经验的程序员几乎一模一样。这个"自己决定"是从哪里来的?
拆开看,至少有三股力量推着它往这个方向走:
- system prompt:基础 prompt 里有一句"先弄清楚再动手:用 list_files 了解项目结构,用 read_file 读相关文件"(第 15 篇);
- 工具描述:
list_files的描述最后一句是"了解一个陌生项目时,先用它看看结构"; - 训练里的习惯:第 11 篇 3.3 节说过,模型在训练中看过大量"一个程序员怎么熟悉一个项目"的多步示范,先看目录、再看 README,本来就是它最熟悉的路径。
前两股是你写进上下文里的,第三股是模型自带的。它们指向同一个方向,模型就走得很顺;如果它们互相矛盾,比如你在 system prompt 里要求"直接读 main.go,不要列目录",模型就会在"你的要求"和"它的习惯"之间摇摆。
所以"它活了"的那个瞬间,其实是你给的上下文和它自带的习惯配合得刚刚好。这也是为什么后面几篇加工具、写描述时,我们会一直顺着它的习惯来,而不是逆着它。
四、read_file:读文件也有讲究
4.1 定义
func (ReadFile) Definition() llm.Tool {
return llm.Tool{
Name: "read_file",
Description: "读取一个文本文件,每行前面带行号。大文件请用 offset 和 limit 分段读取。" +
"修改文件之前应该先读一下。",
InputSchema: schema(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径,相对于工作目录"},
"offset": {"type": "integer", "description": "从第几行开始读(从 1 开始),默认 1"},
"limit": {"type": "integer", "description": "最多读多少行,默认 2000"}
},
"required": ["path"]
}`),
}
}只有 path 是必填的。大部分时候模型只需要给一个路径;遇到大文件,才需要用 offset 和 limit 分段读。
4.2 每行带行号
read_file 的输出,每一行前面都有一个行号:
1 package main
2
3 import (
4 "fmt"为什么要带行号?第 06 篇说过,模型数不清行。让它说"问题在第 37 行",如果没有行号,它只能估;有了行号,它直接照着读出来的数字说。它在回答里引用代码位置、你去对照时,都准确得多。
行号也带来了一个副作用:模型在用 edit_file 改文件时,有时会把行号也抄进要替换的原文里。第 26 篇会讲怎么处理这个问题。
4.3 大文件分段,并且告诉模型还有多少
end := min(in.Offset-1+in.Limit, len(lines))
// ……逐行输出
if end < len(lines) {
fmt.Fprintf(&sb, "……(共 %d 行,已显示 %d-%d 行。用 offset=%d 继续读)\n", len(lines), in.Offset, end, end+1)
}一个 5000 行的文件,默认只返回前 2000 行,结尾加一句"共 5000 行,已显示 1-2000 行,用 offset=2001 继续读"。模型知道还有多少没读,也知道怎么继续读,如果它需要的话。很多时候它不需要:它要找的东西就在前面。
4.4 几种"不该读"的情况
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("文件不存在:%s。可以先用 list_files 看看目录里有什么", in.Path)
}
return "", err
}
if !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 {
return "", fmt.Errorf("%s 看起来是二进制文件(%d 字节),无法按文本读取", in.Path, len(data))
}- 文件不存在:错误信息里告诉它下一步怎么办,"可以先用 list_files 看看";
- 二进制文件:图片、编译产物、数据库文件,读进来是一堆乱码,白白占几万 token。检查一下是不是合法的 UTF-8、有没有零字节,是二进制就直接拒绝;
- 超长的单行:压缩过的 JavaScript、一行几万字的 JSON,单独截断每一行(
maxLineLen = 2000)。
4.5 2000 这个数字是怎么来的
默认最多读 2000 行,这个数字没有什么理论依据,是在两件事之间取的一个平衡:
- 读得太少:每次只给 200 行,模型读一个 800 行的文件要调用四次工具,四圈循环,每一圈都要把越来越长的历史重新发一遍(第 02 篇)。圈数多了,又慢又贵;
- 读得太多:每次给 2 万行,一次就可能把几十万 token 塞进上下文,而且之后的每一圈都要带着(第 28 篇)。模型要找的那十几行,被淹没在几万行里(第 10 篇)。
2000 行大约是一个中等大小的源文件,大部分文件一次就能读完;碰到真正的大文件,结尾那句"用 offset 继续读"会提醒模型分段。成品的编码 Agent 也大多用类似的思路:给一个默认上限,加上分段读取的参数(截至写作时,具体数字各家不同)。
如果你发现模型经常先把一个大文件从头读到尾、再在里面找东西,那说明它缺一个搜索工具。第 18 篇说过,"先搜后读"比"通读"省得多。mini-agent 的做法是让它用 run_bash 跑 grep(第 27 篇)。
4.6 记下"读过了"
read_file 成功读取之后,还会调用一行 t.WS.markRead(path),记下这个文件被读取时的修改时间。这一行是给下一篇的 edit_file 用的:改一个文件之前,必须先读过它,而且读过之后它没有被别人改过。 下一篇细讲。
五、list_files:先看看有什么
5.1 定义
Description: "列出目录下的文件和子目录(目录名以 / 结尾)。recursive=true 时递归列出," +
"会自动跳过 .git、node_modules 等目录。了解一个陌生项目时,先用它看看结构。",最后一句"了解一个陌生项目时,先用它看看结构",是在告诉模型什么时候该用它。开场那一幕,模型第一步就调用了 list_files,很可能就是这句话的功劳。
5.2 跳过无用的目录
// 这些目录里通常是依赖或构建产物,列出来只会浪费上下文。
var skipDirs = map[string]bool{".git": true, "node_modules": true, "vendor": true, ".idea": true, "dist": true, "__pycache__": true}一个 Node.js 项目的 node_modules 里可能有几万个文件。递归列目录时不跳过它,一次调用就能把上下文撑爆,而且里面没有任何模型需要的东西。
另外还有一个总数上限:最多列 500 项,超过就截断,并提示"请指定更具体的子目录"。
5.3 输出格式
README.md
agent/
cmd/
go.mod
internal/一行一个,按名字排序,目录名以 / 结尾。简单到不能再简单,模型一眼就能看懂哪些是文件、哪些是目录。没有文件大小、没有修改时间、没有权限位,因为模型在这一步用不上它们。
5.4 为什么不直接让它用 cat 和 ls
一个很自然的问题:反正第 27 篇会给它一个能跑命令的 run_bash,到时候它用 cat 读文件、用 ls 列目录不就行了,为什么还要专门写这两个工具?
因为专门的工具能做到几件命令行做不到的事:
read_file / list_files | cat / ls | |
|---|---|---|
| 权限 | 只读,永远放行,不用每次问你(第 27 篇) | 跑命令要过权限检查 |
| 工作目录限制 | 每个路径都经过 Resolve,出不去 | 命令想读哪里就读哪里 |
| 输出 | 带行号、分段、拒绝二进制、跳过依赖目录 | 原样输出,一个二进制文件就是满屏乱码 |
和 edit_file 配合 | 读过就记一笔,改之前能检查 | edit_file 不知道它读过没有 |
最后一行是下一篇的关键:edit_file 要求"改之前必须先读过",靠的就是 read_file 里的那一行 markRead。模型如果是用 cat 看的文件,edit_file 是不认的。
专门的工具,是把"一个人用命令行的经验"固化成了模型不容易用错的形状。 成品的编码 Agent 也是这么做的:它们大都有一个能跑任意命令的终端工具,同时又单独提供读文件、搜索、列目录的工具(第 32 篇)。
六、不让它跑出工作目录
6.1 Workspace
两个工具都有一个 WS Workspace 字段,它代表"工具能看到的那个目录":
// Workspace 是工具能看到的那个目录。所有相对路径都相对于它。
type Workspace struct {
Root string
// ……
}模型给的路径,都要经过 Resolve 解析:
// Resolve 把模型给的路径解析成绝对路径,并且不允许跑出工作目录。
//
// 用 EvalSymlinks 是为了挡住"工作目录里放一个指向 /etc 的软链接"这种绕法。
func (w Workspace) Resolve(p string) (string, error) {
if p == "" {
p = "."
}
if !filepath.IsAbs(p) {
p = filepath.Join(w.Root, p)
}
p = filepath.Clean(p)
// ……解析软链接,检查真实路径在不在工作目录下
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
return "", fmt.Errorf("路径 %s 在工作目录 %s 之外,不允许访问", p, w.Root)
}
return p, nil
}它挡住了几种越界的方式:
- 绝对路径:
/etc/passwd; - 用
..往上跳:../../.ssh/id_rsa。filepath.Clean会把它规范化成真实的路径,再检查; - 软链接:工作目录里有一个指向
/etc的软链接link,读link/passwd。路径字面上在工作目录里,实际指向外面。EvalSymlinks把软链接解析成真实路径,再检查。
还有一个细节:检查用的是 root + "/" 作为前缀,而不是 root。否则工作目录是 /home/me/proj 时,/home/me/proj-secret 也会被当成"在工作目录里"。
6.2 为什么要这么严格
一个只能读文件的 Agent,为什么要这么防?因为模型会被诱导。第 23 篇的工具投毒,第 38 篇的 prompt injection,都能让模型去读它本不该读的文件:你的 SSH 私钥、你的云服务凭证、你家目录下的配置文件。读进来之后,它们就在上下文里了;如果再有一个能往外发数据的工具,后果不堪设想。
限制工作目录,是 Agent 安全的第一道墙。 第 27 篇给 Agent 加上终端之后,这道墙会面临更大的挑战。
6.3 这道墙挡不住什么
也要清楚它的边界:
- 它只管文件工具:
Resolve只在read_file、list_files、edit_file里被调用。第 27 篇的run_bash虽然也在工作目录下执行命令,但一句cat ~/.ssh/config,这道墙是拦不住的。终端命令要靠另一套机制:权限检查和人工确认; - 它不管工作目录里面的敏感文件:项目根目录下的
.env、本地的数据库文件,都在工作目录"里面",Resolve照样放行。第 14 篇的坑 7 说过,读进来的东西都会被发给模型服务商; - 检查和使用之间有一个缝:
Resolve检查完路径、到真正os.ReadFile之间,如果有另一个进程恰好把某个目录换成了指向外面的软链接,检查就白做了。对一个跑在你自己电脑上的 Agent,这种攻击的门槛很高,mini-agent 没有处理它,但你要知道它的存在。
一道墙只能挡住它设计时考虑到的那几种越界方式。 安全从来不是靠一道墙,而是靠好几道互相补位的墙:路径限制、权限检查、人工确认,再加上第 38 篇的"致命三件套"防护。
七、动手:看着它活过来
实验 1:在一个陌生的项目里问它
要看模型自己做决定,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。在线 Playground 跑不了 mini-agent,请在本地或 Codespaces 里运行。找一个你电脑上的项目,或者随便 clone 一个开源项目,在那个目录里启动 mini-agent:
export DEEPSEEK_API_KEY=sk-...
cd 某个项目
go run /path/to/agent-basics/cmd/mini-agent(go run 后面写配套仓库里 cmd/mini-agent 的路径。也可以先在仓库根目录 go build -o ~/bin/mini-agent ./cmd/mini-agent,之后在任何目录直接运行 mini-agent。)
问它:"这个项目是干什么的?怎么运行它?"然后看它的工具调用:先调了什么,读了几个文件,按什么顺序读的。
不设 Key 也能跑,但演示模型只会固定地"先列目录、再读 README",没有"自己决定"的那一步。
实验 2:试试越界
问它:"读一下 /etc/hosts 的内容。"或者:"读一下 ../ 目录下有什么。"看看它收到了什么样的错误信息,以及它怎么回应你。
实验 3:读测试
agent/agent_test.go 里的 TestLoopRunsToolsUntilDone,用假模型演了这样一出戏:第一轮调用 list_files;第二轮同时调用 read_file 和一个不存在的工具 no_such_tool;第三轮给出回答。测试检查了循环转了三圈、两个工具都有结果、不存在的工具得到了错误结果。
agent/tools/files_test.go 里有 read_file 和 list_files 的单元测试:行号、分段读取、文件不存在时的提示、二进制文件、跳过 .git。越界路径的检查在 bash_test.go 的 TestWorkspaceConfinement 里:../x、/etc/passwd、指向外面的软链接,都必须被拒绝。
go test ./agent/... -run 'TestLoopRunsToolsUntilDone|TestReadFile|TestListFiles|TestWorkspaceConfinement' -v实验 4:自己写第三个工具
照着 ReadFile 的样子,写一个 search_code 工具:参数是一个关键词和一个可选的目录,在工作目录下逐行查找,返回"文件:行号: 内容",最多 50 行,超过就提示"请换一个更具体的关键词"。记得:
- 路径要经过
WS.Resolve; - 跳过
skipDirs里那些目录; - 描述里写清楚它和
read_file怎么配合,比如"找到位置之后,用 read_file 的 offset 读附近的代码"。
把它注册进 cmd/mini-agent/main.go 里的 tools.NewRegistry(...),接上真实模型,问一个"某某函数在哪里定义"的问题,看看模型会不会主动用它,而不是去跑 grep。
八、踩坑记录:第一个工具的六个坑
坑 1:工具定义的顺序不固定。
从 map 里遍历出来,每次顺序都不一样,缓存永远命中不了。排个序,一行代码。
坑 2:工具出错就中断循环。
文件不存在,Run 返回一个 error,循环直接 return err,整个任务就失败了。工具的错误要变成 tool_result 交给模型,它通常能自己换个路径再试。
坑 3:模型调用了不存在的工具,程序崩了。
用 map 取工具时没检查 ok,拿到一个 nil,调用它的方法就 panic 了。不存在的工具也要返回一个结果,并列出可用的工具。
坑 4:读进来一个二进制文件。
模型想看看某个目录里的东西,顺手读了一个 .png 或者编译产物,几万 token 的乱码进了上下文。读之前检查一下是不是文本。
坑 5:递归列目录没有跳过 node_modules。
一次 list_files 返回几万行,上下文直接爆掉。跳过依赖和构建目录,再加一个总数上限。
坑 6:只用字符串前缀判断路径是否越界。
没有解析 ..,没有解析软链接,或者前缀判断没加路径分隔符。路径检查是安全检查,要按最坏的情况来写,而且要有测试。
一句话总结
一个工具是一份给模型看的定义加一段给程序跑的代码;注册表把定义按名字排好序交给模型,再按名字分发调用,永远返回一个 tool_result,出错也是结果;read_file 带行号、分段读、拒绝二进制,list_files 跳过依赖目录,两者都不允许跑出工作目录。加上这两个工具,那个循环第一次真正转了起来。
不许出界"] W --> F["文件系统"] F -->|"结果"| R R -->|"tool_result"| M M -->|"read_file"| R style R fill:#fff3e0,stroke:#e65100 style W fill:#ffcdd2,stroke:#b71c1c
评论
还没有评论,来说点什么吧。