Book 第十一章:搜索面为什么要分成两层
第三部分:本地执行面

第十一章:搜索面为什么要分成两层

拆开 Glob 和 Grep 两层搜索面,理解它们在代码探索链路里的分工。

1. 为什么这两个工具应该放在一起看

如果只按文件名看:

  • GlobTool 是找文件
  • GrepTool 是搜内容

但在 Claude Code 里,它们并不是两块互不相干的小工具,而是一组很典型的“搜索子系统”:

  1. 先缩小候选文件集合
  2. 再在内容里做更细搜索
  3. 整个过程尽量不走 Bash
  4. 结果对模型、UI、权限系统都更友好

这就是为什么我不建议把它们拆成两篇单独文档。

它们的关系更像:

Claude Code 用原生搜索协议,替代 find / rg / grep 这类 shell 搜索链。

2. 先说结论

我对这组实现的判断是:

GlobToolGrepTool 的核心价值,不是“能搜”,而是把搜索从 Bash 世界搬进 Claude Code 自己的运行时里。

这样做带来的收益非常明确:

  • 权限系统可以真正介入搜索过程
  • 结果天然是结构化的
  • transcript 不会被 shell 噪音污染
  • UI 可以按“搜索结果”而不是“命令输出”展示
  • 模型能用统一参数描述搜索意图,而不是自己拼命令

这也解释了为什么 GrepTool 的 prompt 会明确要求:

  • 搜索任务必须优先用 GrepTool
  • 不要在 BashTool 里直接跑 grep / rg

3. 源码锚点

建议先看这些位置:

  • src/tools/GlobTool/GlobTool.ts:57
  • src/tools/GlobTool/GlobTool.ts:76
  • src/tools/GlobTool/GlobTool.ts:94
  • src/tools/GlobTool/GlobTool.ts:135
  • src/tools/GlobTool/GlobTool.ts:154
  • src/tools/GlobTool/GlobTool.ts:177
  • src/tools/GrepTool/GrepTool.ts:160
  • src/tools/GrepTool/GrepTool.ts:183
  • src/tools/GrepTool/GrepTool.ts:201
  • src/tools/GrepTool/GrepTool.ts:233
  • src/tools/GrepTool/GrepTool.ts:254
  • src/tools/GrepTool/GrepTool.ts:310
  • src/tools/GrepTool/GrepTool.ts:520
  • src/tools/GrepTool/prompt.ts:7
  • src/tools/GlobTool/UI.tsx:52
  • src/tools/GrepTool/UI.tsx:165

4. 总体结构图

flowchart TD
    A["模型发起 Glob / Grep"] --> B["validateInput()<br/>路径存在性 / UNC 防护"]
    B --> C["checkPermissions()<br/>read 权限"]
    C --> D{"搜索类型"}

    D --> E["GlobTool<br/>文件名模式匹配"]
    D --> F["GrepTool<br/>内容正则搜索"]

    E --> E1["glob(pattern, path, limit, permissionContext)"]
    E1 --> E2["结果相对路径化"]
    E2 --> E3["截断标记"]

    F --> F1["构造 ripgrep 参数"]
    F1 --> F2["注入 VCS 排除 / ignore patterns / plugin cache 排除"]
    F2 --> F3["ripGrep(args, path)"]
    F3 --> F4{"output_mode"}
    F4 --> G["content"]
    F4 --> H["count"]
    F4 --> I["files_with_matches"]

    G --> G1["head_limit / offset 分页"]
    H --> H1["聚合总命中数"]
    I --> I1["按 mtime 排序 + head_limit / offset"]

    E3 --> J["共享 Search UI 语义"]
    G1 --> J
    H1 --> J
    I1 --> J

这个图里最重要的一点是:

Claude Code 不是把 shell 搜索命令封一层,而是把“搜索”重新定义成了自己的 tool 协议。

5. GlobTool:先找“哪些文件可能相关”

5.1 它本质上是文件集合筛选器

GlobTool 的输入很简单:

  • pattern
  • path

但这个简单很有价值,因为它把“按文件名模式找东西”从 Bash 里完整剥了出来。

它更像是:

在受权限约束的目录树上,做模式匹配。

5.2 它是只读、可并发、安全默认明确的轻工具

GlobTool 显式声明:

  • isConcurrencySafe() => true
  • isReadOnly() => true

这说明 Claude Code 把它归类为典型的轻量只读工具,可以放心和别的安全只读工具并发跑。

5.3 它不是搜内容,只负责搜文件名

Prompt 里也明确说了:

  • 用它按名字或 glob 找文件
  • 如果是多轮开放式搜索,应该考虑 AgentTool

这其实是在给模型划边界:

  • Glob 不是万能发现工具
  • 它只是搜索链的第一步

5.4 它的返回结果不是原始工具输出,而是“文件列表协议”

输出里有:

  • filenames
  • numFiles
  • durationMs
  • truncated

也就是说,Claude Code 把“搜索到一批文件”建模成了结构化结果,不是让模型去解析 find 的 stdout。

6. GrepTool:把 ripgrep 重新包装成 Claude Code 协议

6.1 它不是简单执行 rg

这点很关键。

GrepTool 虽然底层调用的是 ripGrep(...),但上层协议已经完全不是 shell 命令了。

输入不是命令字符串,而是结构化参数:

  • pattern
  • path
  • glob
  • type
  • output_mode
  • -A / -B / -C / context
  • -n
  • -i
  • head_limit
  • offset
  • multiline

这意味着 Claude Code 把 ripgrep 的关键能力重新投影成了自己的 API。

6.2 它明确要求“搜索不要走 Bash”

prompt.ts 里最重要的一句就是:

  • 搜索任务必须优先用 GrepTool
  • 不要在 BashTool 里直接跑 greprg

这不是文案偏好,而是系统设计立场。

作者显然认为:

只要能抽成原生搜索协议,就不要让模型回到 shell 世界自己拼搜索命令。

这里有个挺有意思的细节:

  • GlobTool.userFacingName() 返回 Search
  • GrepTool.userFacingName() 也返回 Search
  • GlobTool 甚至直接复用了 GrepTool 的结果渲染组件

这说明在 Claude Code 的宿主视角里,这两者不是两类用户心智,而是同一类动作:

搜索。

只是:

  • 一个搜文件路径
  • 一个搜文件内容

这很像一个产品级选择:

底层协议分开,用户感知尽量统一。

8. 两者共享的设计思路

8.1 都是只读、并发安全

这说明 Claude Code 很清楚哪些工具可以放心并行化。

8.2 都会先做路径校验

如果 path 提供了:

  • 就先确认它存在
  • GlobTool 还会确认它是目录

而且两者都有同样的 UNC 路径防护:

  • 遇到 \\\\// 开头路径,不提前做文件系统操作

这一点再次说明 Claude Code 的本地工具设计是统一的,不是每个工具各写一套安全逻辑。

8.3 都走统一 read 权限

两者最终都调用:

  • checkReadPermissionForTool(...)

也就是说,搜索虽然不是“读文件内容”本身,但在权限语义上仍然属于 read。

8.4 都会把结果路径相对化,节省 token

无论 Glob 还是 Grep,结果出来以后都会尽量转成相对路径。

这看起来像小优化,其实非常 Claude Code:

路径表示也是上下文预算的一部分。

9. GlobTool 的几个关键细节

9.1 validateInput() 不只是类型检查,还承担了路径修复提示

如果目录不存在,它会:

  • 结合 cwd 给出提示
  • 尝试猜用户是不是把路径写错了

这和 Read / Edit / Write 一脉相承:

Claude Code 的工具报错,不只是报错,还尽量带着“下一步怎么修”。

9.2 执行层直接把 permissionContext 传进底层 glob

GlobTool.call() 调用:

  • glob(pattern, path, { limit, offset }, abortSignal, permissionContext)

这说明它不是只在调用前做一轮“能不能搜”的判断,而是让底层执行本身也感知权限上下文。

这个分层非常好,因为真正的搜索约束不该只靠前置 if。

9.3 结果截断是协议的一部分

GlobTool 会返回:

  • truncated: boolean

并在模型侧结果里显式追加:

  • 结果被截断了,建议缩小路径或模式

这意味着它不是默默丢结果,而是把“结果不完整”也建模成正式反馈。

10. GrepTool 的几个关键细节

10.1 它支持三种输出模式

这个设计非常重要:

  • content
  • files_with_matches
  • count

这说明 Claude Code 不把“搜索结果”固化成一种输出,而是按模型当前真正需要的粒度返回。

你可以把它理解成三种认知层次:

  1. files_with_matches 先知道哪些文件值得看
  2. content 再看命中的上下文行
  3. count 用统计方式快速扫面分布

这比让模型永远拿一坨 grep 行输出高明很多。

10.2 head_limit + offset 不是附属参数,而是正式分页协议

这里做得很细。

默认 head_limit 不是无限,而是 250

只有显式传 0,才表示不限制。

而且它不是只做“截断”,而是明确支持:

  • offset
  • head_limit

两者组合,等价于搜索结果分页。

这个设计非常值钱,因为在大仓库里,“搜索结果太多”不是异常,而是常态。

10.3 content 模式会优先截断,再做路径转换

源码注释写得很清楚:

  • broad pattern 可能返回上万行
  • 所以先应用 head_limit
  • 再做逐行 relativize

这说明作者在意的不只是正确性,还有大结果集下的 CPU 和 token 成本。

10.4 count 模式不只是 passthrough,还会再聚合总命中数

它会从 filename:count 这样的输出里再算出:

  • totalMatches
  • fileCount

也就是说,它不满足于把 ripgrep 的原始输出转交给模型,而是再往上抬一层语义。

10.5 files_with_matches 模式会按修改时间排序

这个地方很值得注意。

ripgrep -l 返回一批文件后,GrepTool 还会:

  • stat 每个文件
  • mtime 倒序排
  • 同时保留测试环境下的确定性排序

这不是 shell 默认行为,而是 Claude Code 自己加的一层结果排序策略。

我觉得背后的设计动机很合理:

最近改过的文件,往往更值得先看。

10.6 单个 stat 失败不会把整批结果打炸

它用的是:

  • Promise.allSettled(...)

原因也写得很明白:

  • ripgrep 扫描和后续 stat 之间,文件可能被删掉

这说明作者对真实文件系统环境很有敬畏,不假设仓库是静止的。

11. GrepTool 额外做了哪些“不是 rg 默认行为”的处理

这一段是最能看出 Claude Code 设计思路的地方。

11.1 自动排除版本控制目录

默认排除:

  • .git
  • .svn
  • .hg
  • .bzr
  • .jj
  • .sl

这不是功能增强,而是噪音治理。

11.2 限制最大列宽,避免超长行污染上下文

它主动加:

  • --max-columns 500

目的很明确:

  • base64
  • 压缩内容
  • minified 文件

不要把搜索结果撑爆。

11.3 只有显式要求时才开 multiline

默认不开:

  • -U
  • --multiline-dotall

这说明作者不想为了“功能更全”牺牲默认性能和结果可控性。

11.4 模式以 - 开头时,会自动改成 -e

这是个很典型的命令行兼容细节。

它防的是:

  • 用户或模型给的 pattern 以 - 开头
  • ripgrep 把它误解成命令行选项

这类处理虽然小,但非常值钱,因为它把很多 shell 层面的坑吸收进工具内部了。

11.5 会把 permission ignore patterns 转成 rg --glob 排除项

这里很关键。

它会从权限上下文里拿:

  • getFileReadIgnorePatterns(...)

再转成 ripgrep 能理解的 glob exclusion。

也就是说,Claude Code 的权限和忽略规则,不只影响“能不能调用工具”,还会深入影响搜索边界本身。

11.6 还会排除 orphaned plugin version directories

这一条非常 Claude Code。

它说明作者很清楚自己的运行时会在工作区附近生成一些插件缓存目录,而这些目录对搜索来说大多是噪音。

所以它不是泛化地说“全部都搜”,而是会主动做产品语义层面的排噪。

12. UI 层也很能说明它们的产品定位

GrepTool 的 UI 不是在展示命令输出,而是在展示搜索摘要:

  • 找到多少 line
  • 找到多少 match
  • 跨多少 file

展开时再看详细内容。

GlobTool 直接复用这套显示。

这说明 Claude Code 很明确地区分:

  • shell 输出
  • 搜索结果

哪怕底层是 ripgrep,到了宿主层它也已经被重新包装成“搜索对象”了。

13. Claude Code 在搜索工具上做了哪些额外处理

把这两份实现收一下,大概可以总结成六层增强。

13.1 协议增强

  • Glob 搜文件名
  • Grep 搜内容
  • Grep 三种输出模式

13.2 权限增强

  • 统一 read 权限
  • UNC 路径防护
  • ignore patterns 下沉到底层搜索

13.3 结果治理增强

  • 默认分页
  • offset 翻页
  • 截断显式反馈
  • 相对路径节省 token

13.4 噪音治理增强

  • 默认排除 VCS 目录
  • 限制超长行
  • 排除 orphaned plugin cache

13.5 产品语义增强

  • 两个工具都对外显示成 Search
  • 共享结果摘要 UI
  • 错误提示尽量简化成用户能懂的话

13.6 执行健壮性增强

  • Promise.allSettled 避免单个文件竞态拖垮整批结果
  • 测试环境下排序可重复

这说明 Claude Code 的搜索工具不是功能型包装,而是运行时级包装。

14. 对 C# 版的翻译建议

如果你后面做 C# 版,我建议不要做一个“大而全的 SearchTool”把所有东西糊在一起。

更稳的拆法是:

14.1 协议层

  • GlobSearchToolDefinition
  • ContentSearchToolDefinition
  • SearchResultMode
  • SearchPagination

14.2 执行层

  • IFileNameSearchService
  • IContentSearchService
  • IRipgrepAdapter
  • IGlobAdapter

14.3 权限与过滤层

  • ISearchPermissionService
  • ISearchIgnorePatternProvider
  • ISearchNoiseFilter

14.4 结果投影层

  • ISearchResultProjector
  • ISearchUiRenderer
  • ISearchIndexProjector

这里我最建议你保留三件事:

  1. GlobGrep 分开建模
  2. Grepoutput_mode + head_limit + offset
  3. 底层搜索执行也感知 permission/ignore context

这三条会直接决定大仓库下的搜索体验是不是可控。

15. 我对这组设计的评价

GlobToolGrepTool 最值得学的地方,不是它们替代了多少 shell 命令,而是它们让“搜索”这件事进入了 Claude Code 自己的运行时治理范围。

一旦搜索进入原生工具层,Claude Code 就能系统性地处理这些问题:

  • 权限
  • token 成本
  • 噪音目录
  • 分页
  • 结果摘要
  • 宿主展示

这和“让模型自己写 rg ... | head ...”完全不是一个层级。

如果你后面做 C# 版,我会把这组工具放在第二梯队优先级里,紧跟在:

  • Read
  • Edit
  • Write
  • Bash

之后。因为它们会直接决定 agent 在中大型仓库里“找东西”的效率上限。