练习 29:更多工具——收网
这一章一口气加四个工具:grep、glob、web_search、web_fetch。
没有任何新机制——每一个都是"一份声明 + 一个干活的函数",注册进练习 6
建的注册表,一行一个。一章能装下四个工具,这件事本身就是论点:
这套模式你已经用了二十多章,熟到可以流水线作业了。
四个工具分两组。grep/glob 是壳,真正干活的是 ripgrep——聪明的
工具知道什么时候该把活儿外包。web_search/web_fetch 是这个 harness
第一次伸手到机器外面:搜索是一条会降级的多后端链(有 API key 用 API,
没有就退到免费抓取),抓网页则要先过练习 23 沙箱的网络开关——那个
埋了三章一直没人消费的 allowNetwork 字段,今天终于接上了电。
敲进去
在练习 28 的代码上继续写。这一章的节奏会比前几章快——四个工具, 每个只讲它独有的那一两个决定,其余都是你闭着眼睛能写的模式。
grep:外包给 ripgrep
第一个决定是不自己写:
// rgPath 找到 ripgrep。没有就明说怎么装——这个工具选择依赖一个几乎
// 人人都装的二进制,而不是用纯 Go 重写它:.gitignore 的语义、二进制
// 文件探测、并行 IO,重写这些是按月计的工作量。octo 更进一步,把 rg
// 直接内嵌进自己的发布包(rgembed),连"没装"这个状态都消灭了。
func rgPath() (string, error) {
p, err := exec.LookPath("rg")
if err != nil {
return "", fmt.Errorf("找不到 ripgrep(rg)。装一下:brew install ripgrep")
}
return p, nil
}
第二个决定是两道上限,各防一种把上下文灌爆的方式:
// grepMaxLines 是一次 grep 最多返回的行数。没有它,一个宽泛的模式
// (比如 `func`)在大仓库上能把几千行命中灌进上下文。超了就截断,并把
// 总数告诉模型——不说总数,模型会把截断的结果当成全部,换个姿势重跑
// 同一个搜索。
const grepMaxLines = 200
// --max-columns 500 把超长的命中行截成 500 字符:一次命中 minified
// 文件或 base64 大字符串,单单一行就能灌爆上下文。--max-columns-preview
// 让截断显示前 500 字节,而不是一句干巴巴的"[行太长已省略]"——octo 的
// 注释记着后者的教训:模型会以为结果不完整,换着花样重跑。
rgArgs := []string{"--color=never", "--line-number", "--max-columns", "500", "--max-columns-preview"}
一个防"行太多",一个防"单行太长",而且两个截断都告诉模型截了多少、 该怎么办——上限不是把话说一半,是把话说清楚的同时把量管住。剩下的 就是参数拼接和一个细节:
out, err := cmd.Output()
if err != nil {
// ripgrep 用退出码 1 表示"没有匹配"。这对模型不是错误,是一个
// 干净的答案。
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return "(没有匹配)"
}
return "错误: rg 执行失败: " + err.Error()
}
glob:枚举也外包,匹配自己来
// rg --files 只枚举不搜索:吐出所有没被 .gitignore 排除的文件路径
// (相对 root)。.git 本身它默认就不进。
cmd := exec.CommandContext(ctx, rg, "--files")
标准库的 path.Match 不支持 **,与其绕着它的语义打补丁,不如十行
编译一个:
// globToRegexp 把 glob 模式编译成正则:`**` 跨目录、`*` 不跨目录、
// `?` 单字符,其余字符原样。
func globToRegexp(pattern string) (*regexp.Regexp, error) {
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(pattern); i++ {
switch c := pattern[i]; c {
case '*':
if i+1 < len(pattern) && pattern[i+1] == '*' {
b.WriteString(`.*`) // ** 跨目录
i++
// 吃掉 **/ 里的斜杠,让 `**/*.go` 也能匹配根目录下的文件
if i+1 < len(pattern) && pattern[i+1] == '/' {
b.WriteString(`/?`)
i++
}
} else {
b.WriteString(`[^/]*`) // * 不跨目录
}
case '?':
b.WriteString(`[^/]`)
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteString("$")
return regexp.Compile(b.String())
}
结果按修改时间倒序:
// 最近改过的排前面:模型找文件多半是为了接着改,新鲜度就是相关性。
sort.Slice(matches, func(i, j int) bool { return matches[i].mtime > matches[j].mtime })
网络开关:埋了三章的字段接上电
// networkAllowed 报告沙箱放不放行网络。没开沙箱就是放行——练习 23 的
// allowNetwork 字段埋了三章,第一个消费者是这里:bash 的联网被沙箱在
// OS 层拦(Seatbelt 的 network 规则),而 web_fetch/web_search 是进程
// 内的 Go 代码,OS 拦不着它们,得自己看开关。同一个开关,两层执法。
func networkAllowed() bool {
return activeSandbox == nil || activeSandbox.allowNetwork
}
web_fetch:伸出去的手,先过开关
func (webFetchTool) execute(ctx context.Context, args string) string {
// 网络开关在最前面:跟 bash 不同,这个工具的 HTTP 请求发自 harness
// 进程本身,OS 沙箱包不住它,只能在代码里自觉——所谓"进程内的工具
// 不过沙箱"(练习 23 点破的洞),补法就是把开关的检查写进工具自己。
if !networkAllowed() {
return "错误: 沙箱关闭了网络访问,web_fetch 不可用"
}
之后是一个装得像浏览器的 GET:
req.Header.Set("User-Agent", browserUserAgent)
// 默认带一个同源 Referer——浏览器在站内跳转就是这么发的,很多防盗链
// 的 403 靠这一个头就能解开。
req.Header.Set("Referer", u.Scheme+"://"+u.Host+"/")
回来的东西过两道筛。二进制直接拒读:
// 只收文本。二进制响应转成字符串是一堆乱码,白白烧上下文,不如一句
// 明白话指个路。
ctype := resp.Header.Get("Content-Type")
if !isTextualContentType(ctype) {
return fmt.Sprintf("这个 URL 返回的是二进制内容(%s),web_fetch 只处理文本。要下载它,用 bash 的 curl -o。", ctype)
}
HTML 默认粗剥成正文:
// stripHTMLToText 把 HTML 粗剥成可读文本:去掉脚本和样式、去掉所有标签、
// 解码实体、压缩空行。这是权宜版——octo 用真正的 HTML 解析器提取正文
// 再转成 Markdown(标题、链接、表格都保留结构),那是一个包的工作量,
// 剥标签是十行的工作量,先用够。
web_search:一条会降级的链
// 组链:有 key 的后端排前面,零 key 的抓取永远垫底。链在每次调用时
// 现组,因为 key 是环境变量,进程活着的时候它也可能变。
var backends []backend
if os.Getenv("BRAVE_SEARCH_API_KEY") != "" {
backends = append(backends, backend{"brave", searchBrave})
}
backends = append(backends, backend{"bing", searchBing})
for _, b := range backends {
results, err := b.run(ctx, in.Query, max)
if err != nil {
lastErr = fmt.Errorf("%s: %w", b.name, err)
// 降级要出声。对模型,成功的响应里只有 provider(别把上一环
// 的尸体塞给它当噪音);但对屏幕前的人,链条断了一环是值得
// 知道的事——不打这一行,你以为自己在用付费后端,实际上 key
// 早就过期了,一直在吃免费抓取的质量。
fmt.Fprintf(os.Stderr, "[web_search: %s 失败(%v),换下一个后端]\n", b.name, err)
continue
}
if len(results) == 0 {
lastErr = fmt.Errorf("%s: 零结果", b.name)
fmt.Fprintf(os.Stderr, "[web_search: %s 零结果,换下一个后端]\n", b.name)
continue
}
// ……取用这一环的结果,break
}
两个后端长得完全不同——brave 是一个 GET 加一个 JSON 解码(付费后端 的样子),bing 是在别人的 HTML 里用正则翻自己要的东西(免费兜底的 样子)——但它们被同一个形状装起来,输出同一种结果:
// searchResponse 是最终交给模型的 JSON。Provider 字段刻意放在结果前面:
// 模型该知道这批结果是真搜索引擎的索引(brave)还是免费抓取(bing)——
// 该不该信、要不要再核实,取决于是谁给的。
type searchResponse struct {
Query string `json:"query"`
Provider string `json:"provider"`
Count int `json:"count"`
Results []searchResult `json:"results"`
Error string `json:"error,omitempty"`
}
注册:四行,收工
// 这一章的四个新工具,四行注册,别处一个字不用动——练习 6 立注册表
// 时许诺的"加工具 = 加一行",第 N 次兑现。排在 subAgent 之前:检索
// 和联网不挑宿主,子 agent 的一次调用之内照样用得上。
toolList = append(toolList, grepTool{}, globTool{}, webSearchTool{}, webFetchTool{})
完整代码见仓库 exercises/ex29/。
跑起来
grep/glob 依赖 ripgrep,先确认装了:
which rg || brew install ripgrep
然后照旧:
cd exercises/ex29
export OPENAI_API_KEY=sk-xxxx
export MODEL=deepseek-v4-flash
export OPENAI_BASE_URL=https://api.deepseek.com/v1
go run .
随便搭个小项目当检索陪练(几个 .go 文件、一层子目录),然后:
用 glob 找出 proj 目录下所有的 .go 文件,然后用 grep 找出 Add 函数
定义在哪个文件的第几行,别用 bash。
你应该看到什么
实验一:检索二连——先圈范围,再找定义
DeepSeek(转写有排版精简):
> 用 glob 找出 proj 目录下所有的 .go 文件,然后用 grep 找出 Add 函数
定义在哪个文件的第几行,别用 bash。
[round 1] glob({"pattern": "proj/**/*.go"})
[round 2] grep({"pattern": "func Add", "path": "proj", "include": "*.go"})
找到了。
- glob 在 proj 下找到 3 个 .go 文件:proj/util/math.go、proj/greet.go、proj/main.go
- grep `func Add` 的命中:Add 函数定义在 proj/util/math.go 的第 4 行:
func Add(a, b int) int { return a + b }
全程用的是 glob 和 grep,没有动用 bash。
本机 Ollama(qwen3:4b-instruct)跑同一个任务同样两步到位,grep 的
模式还写得更讲究——func\s+Add,用 \s+ 兼容多个空格。两个工具的
配合已经是肌肉记忆级别的用法:glob 圈范围,grep 找内容。
实验二:让它读自己这本书
> 用 web_fetch 抓 https://leihb.github.io/learn-agent-the-hard-way/ ,
用一两句话告诉我这本书讲什么、给谁看。
[round 1] web_fetch({"url": "https://leihb.github.io/learn-agent-the-hard-way/"})
这本书讲的是"agent 只有一种架构:LLM + 工具循环",然后手把手带你从
一次 API 调用开始,逐练习亲手写一个生产级 agent harness(工具循环、
权限、记忆、skills、subagent、MCP、沙箱等)。
适合想真正理解 agent 底层原理、愿意动手写代码的人……
它抓的是这本书的在线主页——剥掉标签的正文进了上下文,主旨口号被它 一字不差地读了出来。你正在读的书,成了书里 harness 的第一个抓取对象。
实验三:链条断了一环,降级是什么样
故意设一个假的 API key(BRAVE_SEARCH_API_KEY=fake-key-for-demo),
让链条的第一环必然失败:
> 用 web_search 搜「ripgrep 是什么」,汇报前三条结果,并明确告诉我
provider 字段说结果来自哪个后端。
[round 1] web_search({"query": "ripgrep 是什么", "max_results": 3})
[web_search: brave 失败(HTTP 422),换下一个后端]
搜索完成。以下是前三条结果:
1. ripgrep 完全指南:比 grep 快 100 倍的命令行搜索利器(掘金)……
2. ripgrep: 更快捷的搜索(简介与原理)(知乎)……
3. claude切换为了ripgrep,什么是ripgrep?为什么要切换?(知乎)……
Provider 说明:这次返回的 provider 字段是 bing,也就是说结果来自
Bing 搜索后端(免费抓取后端,未设置 BRAVE_SEARCH_API_KEY)。
假 key 换来一个结结实实的报错,链条顺到 bing,模型拿到结果照常干活,
provider 字段如实交代了出处。
但看它最后那句解释——"未设置 BRAVE_SEARCH_API_KEY"。错的。key 设了, 只是假的。这个实验跑了两遍,模型两遍都编了这个解释:模型看不见的 事,它会用最顺手的故事补上。那行"[brave 失败,换下一个后端]"是 打给屏幕前的你的(stderr,不进对话——不打这行,你以为自己在用付费 后端,实际上 key 早过期了);而模型的响应里只有 provider,上一环的 失败细节对它是噪音。人和模型各看各的渠道,这是两个独立的设计决定 ——代价就是模型的归因只能靠猜,猜错也无伤大雅:它需要知道的只是 "这批结果是谁给的",这一点它说对了。
实验四:网络开关,一手拦一手放
-sandbox 启动(网络关闭),一句话让它同时碰网络和本地:
[round 1] web_fetch({"url": "https://example.com"})
[round 1] grep({"pattern": "proj", "mode": "files_with_matches"})
两件事都执行完了,结果如下:
1. web_fetch 抓 https://example.com
失败。返回错误:沙箱关闭了网络访问,web_fetch 不可用——当前环境的
沙箱禁用了网络,所以这个工具在这个会话里用不了,不是 URL 本身的问题。
2. grep 在当前目录找包含 proj 的文件
成功,命中 1 个文件:exp4.log
(grep 是按内容匹配,不是按文件名匹配,所以找到的是"内容里有 proj"
的文件)
同一轮里,网络工具被开关拦下、本地工具照常干活,模型对两个结果的 解读都准确——顺带还纠正了提问里"找文件名"的措辞误导:grep 匹配的是 内容。练习 23 的开关管住了三章之后才出生的工具,这就是把开关做成 策略字段而不是散落判断的回报。
发生了什么
收网:四个工具,零个新机制
把这一章用到的机制列出来,每一个都有出处:
| 这一章用到的 | 哪一章建的 |
|---|---|
| toolSpec + execute 的工具形状 | 练习 5 |
| 注册表,加工具 = 加一行 | 练习 6 |
| ctx 穿进 execute(超时、打断) | 练习 24 |
| 输出上限 + 截断要说明白 | 练习 12 的估算、练习 28 的缓冲上限,同一族 |
| 沙箱的 allowNetwork 策略字段 | 练习 23 |
| 子 agent 共享工具表 | 练习 19 |
这就是"收网"的意思:网早就织好了,这一章只是收拢渔网,把鱼捞上来。octo 里这四个 工具的实现文件加起来一千多行,没有一行需要改动 harness 的骨架—— 对一个架构最好的检验,不是它能装下设计时想到的东西,是它能不能装下 设计时没想到的东西。
外包,还是自造
grep 的第一行代码是去找别人的二进制。这个决定值得单独说:ripgrep
处理 .gitignore、探测二进制文件、并行扫描,这些活儿用纯 Go 重写是
按月计的工作量,而 exec.LookPath("rg") 是一行。工具的价值在于给
模型一个干净的能力接口,不在于接口后面的活儿是谁干的——把 harness
写小的诀窍之一,就是把能外包的都外包出去。octo 干脆把 rg 内嵌进
发布包(go:embed 一个 12MB 的二进制),连"用户没装"这个状态都消灭了。
glob 是同一个思路的变体:枚举外包(rg --files 顺带把 .gitignore
处理了),匹配自己写(十行把 glob 编译成正则,因为标准库不认 **)。
外包不是全包,是各干各擅长的。
同是进程内的 HTTP,为什么一个看开关、一个不看
web_fetch 的第一行是查 networkAllowed()。但 harness 里还有一处
天天在发 HTTP 的代码——send(),发给模型 API 的那个。它不看开关。
这不是疏忽。沙箱关网关的是模型伸出去的手:它能抓什么网页、能把
数据发到哪里去——这些是要被管住的行为。而 send() 是模型的血管,
掐了它整个 agent 就死了。同一个进程里的两种 HTTP,一种是能力,一种
是生命维持,开关只管前者。练习 23 说过"进程内的工具不过沙箱"是个洞
——这一章补洞的方式不是把洞堵死,是在洞口装了一个只拦该拦的门。
链的诚实:降级可以,冒充不行
web_search 的链条允许每一环失败,但有一条不许破:结果是谁给的, 就说是谁给的。provider 字段随结果一起交给模型,付费索引和免费抓取 的可信度差一截,该不该二次核实是模型要做的判断,前提是它得知道。 至于降级过程本身,对人出声、对模型只给结论——实验三里那两次一模一样 的错误归因,就是这个取舍的代价,也是可以接受的代价。
上限都是信息设计
这一章每个工具都带上限:grep 200 行、单行 500 字符、glob 200 个路径、 web_fetch 16KB。上限本身不稀奇(练习 12 起就在管上下文的账),稀奇的 是每个截断都在说话:
- grep 截断报总数:"共 3172 行"——不报,模型把片段当全集;
- 超长行给前 500 字节预览——给一句"[行太长已省略]",模型会以为结果 坏了而重跑(octo 注释里记着这个真实教训);
- glob 截断报总数,web_fetch 保尾部。
对模型这种读者,截断不说明白等于撒谎——它没有别的渠道核实你给的是 不是全部。
常见问题
**这四个工具为什么不过批准门?**注册表的权限检查只拦 bash 和写文件 (练习 9 的设计),grep/glob/web_search/web_fetch 都是直通的——本地 检索是只读的,网络工具零登录态、只碰公开网页。octo 的默认权限也是 这么划的。但"web_fetch 该不该问一声"值得想:URL 本身可以携带信息 (比如把本地数据编码进查询参数发出去),这是一条没装门的出网通道 ——加分练习 1 就是这个。
**HTML 剥标签太粗糙了吧?**是。导航、页脚、广告的文字全混在正文里,
表格和链接的结构也没了。octo 的做法是完整解析 HTML、提取正文区域、
转成 Markdown(标题层级、链接、表格都保留),代价是一整个解析器包。
教学版选了十行的粗剥——但接口留好了:clean 参数的语义和 octo 一致,
升级实现不用动声明。
**web_search 为什么不用官方搜索 API 的免费档?**免费档也要注册拿 key, 而这个工具的承诺是"零配置能用"。抓取的质量确实不如索引(provider 字段就是为这个差距设的),但一个开箱即用的兜底 + 一个设了 key 就 自动启用的更好后端,比"先去注册个账号"友好得多。octo 的链更长:brave → tavily → serper → duckduckgo → bing,形状一样,环数而已。
**octo 的版本还有什么没搬?**grep 的 before/after 单侧上下文参数、 rgembed(内嵌 rg 二进制);glob 的字面前缀剪枝(模式有非通配前缀时 只扫那个子树,别每次全仓枚举)和大小写敏感处理;web_fetch 的 referer/user_agent 覆盖参数(解顽固的防盗链 403)、编码探测(GBK/ Big5 转 UTF-8)、大响应溢出到临时文件;web_search 的另外三个后端。 都是肉,骨架都在这一章里。
加分练习
- 给 web_fetch 装一道 ask 门。URL 里可以编码任何东西——让每次 出网抓取都过一遍批准(或者只对非常见域名问),体会一下"便利"和 "出网通道"的换算关系。门装在注册表层还是工具里?想想练习 9 的 理由再动手。
- 给链加一环。选 Tavily 或 DuckDuckGo 照着 searchBrave/searchBing 写一个后端函数,插进链里——感受一下"加一环 = 一个函数 + 一行 append"。octo 的五环链就是这么长出来的。
- 内嵌 ripgrep。用 go:embed 把 rg 二进制打进程序,启动时解压到 缓存目录——octo 的 rgembed 消灭了"用户没装 rg"这个状态,代价是 发布包胖 12MB。掂量一下这笔交易。
- web_fetch 大响应溢出。超过上限不截断,写进临时文件,返回 预览 + 文件路径,让模型用 read_file/grep 自己翻——octo 的 MaybeSpillOutput,把"上下文的账"转成"文件系统的账"。
- glob 剪枝。
src/**/*.ts这种模式只可能命中 src/ 底下,没必要 全仓枚举再过滤。从模式里抠出字面前缀当扫描根——octo 的 literalPathPrefix,一个纯优化,但大仓库里是"每次 glob 都全仓扫" 和"只扫一个子树"的区别。