# Step 16: Grep 搜索工具

一句话导读:Glob 找文件名,Grep 找文件里的内容。它第一次逼你正面回答一个 Agent 工具的必修题——返回结果太大怎么办。答案是"截断",而截断怎么做,藏着对上下文预算的敬畏。


# 一、这一步做了什么(What)

在 step15(已有 Bash / Read / Write / Glob)基础上,新增第五个工具 GrepTool:

  1. 新建 tools/grep.ts,纯 Node.js 实现,不依赖系统 grep;
  2. 组合"Glob 找文件 + readFileSync 读内容 + filter 匹配行"三步;
  3. 每个文件最多显示 5 行匹配,超出用 ... +N more 提示;
  4. index.ts 注册 GrepTool,工具总数 6 个。

输入两个参数:pattern(要搜的文本)和可选的 glob(限定文件范围,默认 **/*)。


# 二、面试官视角:为什么要做?(Why)

面试题:Grep 工具里那句"每个文件最多显示 5 行",看起来很不起眼,删掉它功能也正常。为什么它其实是这个工具最重要的一行?

因为它是上下文预算的保险丝。

搜索类工具的返回长度是不可预测的:搜一个常见词如 import,可能在几百个文件里命中上万行。如果全量返回:

  • 一次工具调用就可能塞进几万 token,一口气吃掉大半个上下文窗口;
  • 后续对话空间被挤没,模型开始"失忆"、丢失早期指令;
  • 更糟的是,海量无关匹配会淹没真正有用的那几行,模型反而更难聚焦。
维度 不截断 每文件截断 5 行
Token 消耗 单次可达数万,不可控 有明确上界,可预算
信噪比 有用信息被淹没 每个文件给足"够定位"的样本
后果 上下文爆炸,对话崩溃 需要更多再让 AI 缩小 glob 精搜

一句话:搜索工具的核心难点不是"怎么找到",而是"找到太多时怎么办"。截断是把"内容无限"的现实,压回到"上下文有限"的约束里。


# 三、原理:它是怎么工作的(How)

# 真实代码

tools/grep.ts 的主体逻辑:

async execute(input: ToolInput): Promise<ToolResult> {
  const pattern = (input.pattern as string || '').toLowerCase();  // ★ 统一小写 = 大小写不敏感
  const fileGlob = (input.glob as string) || '**/*';

  const files = fg.sync(fileGlob, { dot: true, ignore: ['node_modules/**'] });  // ★ 默认忽略依赖
  const results: string[] = [];
  let totalFiles = 0;

  for (const file of files) {
    try {
      const content = readFileSync(file, 'utf-8');
      const lines = content.split('\n');
      const matches = lines
        .map((line, i) => ({ line, num: i + 1 }))       // 保留行号
        .filter(({ line }) => line.toLowerCase().includes(pattern));

      if (matches.length > 0) {
        results.push(file + ':');
        for (const m of matches.slice(0, 5)) {          // ★ 每文件最多 5 行
          results.push('  ' + m.num + ': ' + m.line.trim().slice(0, 120));  // ★ 每行最多 120 字符
        }
        if (matches.length > 5) results.push('  ... +' + (matches.length - 5) + ' more');
        totalFiles++;
      }
    } catch {
      // skip unreadable files  ← 二进制/无权限文件静默跳过
    }
  }
  // ...汇总输出
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

注意这里有三重截断,缺一不可:

  1. 每文件 5 行(matches.slice(0, 5))——防单文件刷屏;
  2. 每行 120 字符(.slice(0, 120))——防某一行是压缩过的巨长代码;
  3. 忽略 node_modules(ignore)——防在第三方依赖里搜出海量噪声。

# 数据流

AI 想在 TS 文件里找 "Tool"
   ↓ tool_use { name:"Grep", input:{ pattern:"Tool", glob:"**/*.ts" } }
GrepTool.execute
   ↓ ① fg.sync 找到候选文件 (排除 node_modules)
   ↓ ② 逐个 readFileSync + split('\n')
   ↓ ③ filter 出含 "tool" 的行 (小写比较)
   ↓ ④ 每文件取前 5 行 + 行号 + 截断 120 字符
"搜索 "tool" 在 **/*.ts 中找到 3 个文件:
 tools/glob.ts:
   12: export const GlobTool = {
   ..."
   ↓ 作为 tool_result 回传
AI 拿到"哪些文件、哪些行",决定 Read 精读
1
2
3
4
5
6
7
8
9
10
11
12
13

# 为什么"组合 Glob + read + filter"而不是调系统 grep?

和 Glob 工具同理,但这里多一层考虑:统一沙箱。如果 Grep 走 child_process 调系统 grep,那它就变成一次进程执行,得走 Bash 那套高危权限,还得处理 shell 转义、注入风险。用纯 Node 实现,它就是个"只读文件"的安全动作,跨平台一致,也不给命令注入留口子。能力越窄,越安全,越可控。


# 四、深入追问(面试常见 follow-up)

Q:这个实现用 line.includes(pattern) 做纯字符串匹配,和真实 grep 的正则有什么区别?会漏什么场景? A:漏了所有正则能力——你没法搜 function\s+\w+、没法用 ^import、没法用 \bTool\b 做词边界。教学版够用是因为 AI 常搜的是具体符号名。真实 GrepTool 底层是 ripgrep,支持完整正则 + 多行模式。代价是正则要处理 ReDoS(灾难性回溯)风险,字符串匹配则天然安全。

Q:readFileSync 逐个同步读文件,成百上千个文件时会怎样? A:会同步阻塞、慢,且在 step18 并行工具时会卡住事件循环。真实版用流式/异步读,且遇到超大文件会跳过或只读前 N 行。这是"教学简单"与"生产健壮"的又一处分野。

Q:截断成 5 行,万一 AI 要的那第 6 个匹配恰好被砍掉了,岂不是给了它错误的全貌? A:这是刻意的权衡。工具给的是"样本 + 总数提示"(+N more 告诉 AI 还有更多),而非"完整答案"。AI 看到样本后若觉得不够,正确反应是缩小 glob 范围再搜一次,而不是要求全量。把搜索设计成"迭代收敛"而非"一次到位",正是应对信息过载的标准姿势。

Q:catch {} 把读不了的文件静默跳过,会不会掩盖问题? A:这是有意的容错。目录里必然混着二进制文件、无读权限文件、软链坏掉的文件,逐个报错会打断整个搜索。搜索工具的契约是"尽力返回能搜到的",个别文件失败不该让整次调用失败——这叫局部失败隔离,step18 的并行工具里会把它上升为一条通用原则。


# 五、踩坑 / 设计权衡

  • 大小写默认不敏感:toLowerCase() 两边都转小写,对"我大概记得有个叫 tool 的东西"很友好,但想精确区分 Tool 和 tool 时就无能为力。真实版有大小写开关。
  • 无上下文行:只给命中行,不给前后 N 行。看一个函数定义时缺上下文,真实版有 -A/-B/-C。
  • 默认排 node_modules 是救命设定:忘了它,第一次搜 import 就能返回几万行依赖代码,直接把演示搞崩。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
tools/grep.ts ~60 行 src/tools/GrepTool/(3 个文件)
String.includes 字符串匹配 正则表达式(ripgrep 内核)
每文件 5 行 / 每行 120 字符硬截断 可配置上限 + 上下文行 -A/-B/-C
toLowerCase 强制大小写不敏感 大小写开关
同步 readFileSync 异步 / 流式,超大文件保护
catch{} 跳过读不了的文件 同样的局部失败隔离

# 七、一句话总结

Grep 工具 = Glob + 逐文件 filter + 三重截断:它教的不是"怎么在文件里找字符串",而是所有搜索类 Agent 工具的第一性原理——返回内容是无限的,上下文是有限的,工具的本事在于用截断和"总数提示"把无限压回有限,让 AI 迭代收敛而不是一次撑爆。

# 下一节预告

工具已经攒到 6 个,但至今都得手动 /run。下一步 step17 是 Phase 2 的质变点——AI 第一次能自己决定何时调用工具,Tool Calling 循环登场。