练习 29:更多工具——收网

这一章一口气加四个工具:grepglobweb_searchweb_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 的另外三个后端。 都是肉,骨架都在这一章里。

加分练习

  1. 给 web_fetch 装一道 ask 门。URL 里可以编码任何东西——让每次 出网抓取都过一遍批准(或者只对非常见域名问),体会一下"便利"和 "出网通道"的换算关系。门装在注册表层还是工具里?想想练习 9 的 理由再动手。
  2. 给链加一环。选 Tavily 或 DuckDuckGo 照着 searchBrave/searchBing 写一个后端函数,插进链里——感受一下"加一环 = 一个函数 + 一行 append"。octo 的五环链就是这么长出来的。
  3. 内嵌 ripgrep。用 go:embed 把 rg 二进制打进程序,启动时解压到 缓存目录——octo 的 rgembed 消灭了"用户没装 rg"这个状态,代价是 发布包胖 12MB。掂量一下这笔交易。
  4. web_fetch 大响应溢出。超过上限不截断,写进临时文件,返回 预览 + 文件路径,让模型用 read_file/grep 自己翻——octo 的 MaybeSpillOutput,把"上下文的账"转成"文件系统的账"。
  5. glob 剪枝src/**/*.ts 这种模式只可能命中 src/ 底下,没必要 全仓枚举再过滤。从模式里抠出字面前缀当扫描根——octo 的 literalPathPrefix,一个纯优化,但大仓库里是"每次 glob 都全仓扫" 和"只扫一个子树"的区别。