手写 Agent ②,第一个工具,read_file 与 list_files

为 mini-agent 注册 read_file 和 list_files,让它自行探索项目;同时处理路径边界、工具分发和目录安全。

outlier 17 分钟 6455 字 0 0
系列教程Agent从入门到放弃 第 25 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:它活了

我至今记得那个瞬间。

上一篇写完的 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),干活,返回一段文本结果,或者一个错误。
flowchart LR T(("一个工具")) T --- D["Definition()
给模型看
名字、描述、参数 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)
}

几个设计决定,每一个都有原因:

  1. 永远返回一个 tool_result,永远不 panic,永远不返回 error。 第 02 篇的铁律:每个 tool_use 都必须有对应的 tool_result。不管发生什么,这里都保证交回一个结果;
  2. 工具不存在,也是一个结果。 模型有时会"发明"一个不存在的工具名(比如把 read_file 写成 readfile),这时返回一个错误结果,并且列出所有可用的工具(第 20 篇:错误信息就是提示词)。模型看到之后,下一次就会用对;
  3. 工具出错,也是一个结果。 Run 返回的 error,被包装成 is_error: true 的 tool_result。文件不存在、参数不对,这些都不是程序的 bug,模型看到错误通常能自己纠正;
  4. 出错时,已有的输出也带上。 比如一个命令跑到一半失败了,前面的输出和最后的错误信息都有用;
  5. 空结果也要说清楚。 返回一个空字符串,模型可能会困惑"是没执行,还是执行了但没有输出?",写成"(无输出)"就明确了;
  6. 统一截断。 所有工具的输出都在这里经过 Truncate,第 28 篇细讲。
flowchart TB C["一个 tool_use"] --> F{"工具存在吗?"} F -->|"不存在"| E1["tool_result(出错)
没有这个工具,可用的有:……"] 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 打印的。

sequenceDiagram participant A as Agent.Send participant M as 模型 participant R as Registry participant U as UI A->>M: 消息历史 + 工具定义 M-->>A: tool_use: list_files A->>U: ToolCall(list_files) A->>R: Run(call) R-->>A: tool_result A->>U: ToolResult(...) A->>A: appendUser(结果) A->>M: 消息历史(多了两条) M-->>A: tool_use: read_file Note over A: ……循环继续,直到 end_turn

循环就是这样转起来的。 第 02 篇的那二十行伪代码,到这里变成了真正能跑的代码。

3.1 它为什么"知道"先看目录、再读 README

回到开场那一幕:没人告诉它先读哪个文件,它却先列目录,再读 README,再读 go.mod 和 main.go,顺序和一个有经验的程序员几乎一模一样。这个"自己决定"是从哪里来的?

拆开看,至少有三股力量推着它往这个方向走:

  1. system prompt:基础 prompt 里有一句"先弄清楚再动手:用 list_files 了解项目结构,用 read_file 读相关文件"(第 15 篇);
  2. 工具描述:list_files 的描述最后一句是"了解一个陌生项目时,先用它看看结构";
  3. 训练里的习惯:第 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_filescat / 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 跳过依赖目录,两者都不允许跑出工作目录。加上这两个工具,那个循环第一次真正转了起来。

flowchart LR M(("模型")) -->|"list_files"| R["Registry"] R --> W["Workspace
不许出界"] W --> F["文件系统"] F -->|"结果"| R R -->|"tool_result"| M M -->|"read_file"| R style R fill:#fff3e0,stroke:#e65100 style W fill:#ffcdd2,stroke:#b71c1c

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭