# Step 16: Grep 搜索工具
一句话导读:Glob 找文件名,Grep 找文件里的内容。它第一次逼你正面回答一个 Agent 工具的必修题——返回结果太大怎么办。答案是"截断",而截断怎么做,藏着对上下文预算的敬畏。
# 一、这一步做了什么(What)
在 step15(已有 Bash / Read / Write / Glob)基础上,新增第五个工具 GrepTool:
- 新建
tools/grep.ts,纯 Node.js 实现,不依赖系统 grep; - 组合"Glob 找文件 +
readFileSync读内容 +filter匹配行"三步; - 每个文件最多显示 5 行匹配,超出用
... +N more提示; 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 ← 二进制/无权限文件静默跳过
}
}
// ...汇总输出
}
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
注意这里有三重截断,缺一不可:
- 每文件 5 行(
matches.slice(0, 5))——防单文件刷屏; - 每行 120 字符(
.slice(0, 120))——防某一行是压缩过的巨长代码; - 忽略
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 精读
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 循环登场。