拆解 Claude Code 的上下文工程:Grep + Glob + Read 三个工具就够了

6 阅读18分钟

从 Claude Code 源码扒出 Grep、Read、Glob 的实现细节,对比 Cursor 和 Codex 的不同选择,最后整理一个简化版 MCP 工具框架。

问题:Claude Code 怎么"看到"你的代码?

不管是 Claude Code、Cursor 还是 Copilot,当要理解一个代码库时,它们都面临同一个根本问题:token 预算有限,不可能一次性把整个项目喂给模型

怎么解决?先看行业里几个主流工具的做法。它们的选择不光决定搜索速度,还影响隐私、新鲜度和启动成本。大致分三条路:

路线一:建索引,做语义检索

代表是 CursorGitHub Copilot。预处理代码 → 向量化 → 存向量数据库 → 用户提问时语义匹配。

Cursor:打开项目后 tree-sitter 按函数/类分块,转 embedding 存 Turbopuffer,每 5 分钟 Merkle Tree 增量同步,索引到 80% 即可使用。语义搜索与 Grep 结合使用,准确率比单独 Grep 提升 12.5%(官方在 1000+ 文件项目上实测)。Cursor 也内置了 Instant Grep 做精确符号查找。

Copilot:打开仓库对话时自动索引,首次最多 60 秒,后续秒级更新。Agent 实际策略是多工具组合——语义搜索定位大体位置,Grep 查精确模式,Usages 追符号引用,最后读文件确认。

路线二:不建索引,模型自己搜

代表是 Claude CodeCodex CLI。代码不预处理、不向量化,LLM 自己决定用什么关键词搜,搜到结果后自己判断要不要继续。

Claude Code 只靠 Glob、Grep、Read 三个文件系统工具。Boris Cherny 的结论是"Plain glob and grep, driven by the model, beat everything."——这个后面展开说。

Codex CLI 同样零索引。它没有专用搜索工具,一切通过 shell 发 rg 命令。OpenAI 的 Prompting Guide 里直接写:"When searching for text or files, prefer using rg because rg is much faster than alternatives like grep."

路线三:混合模式

Copilot 的 VS Code Agent 实际走这条路:语义索引提供语义搜索,同时配 Grep、Text Search、Usages、File Search 多套工具,Agent 根据任务自动选择。

三种路线的差异汇总:

维度CursorCopilotClaude CodeCodex CLI
索引方式tree-sitter 分块 + 向量嵌入远程语义索引 + 本地 LSP
搜索核心语义检索 + Instant Grep语义检索 + Grep + UsagesLLM 驱动 Grep/Glob/ReadLLM 驱动 rg/find/cat
启动成本需等索引构建首次最多 60s
数据隐私路径加密,代码不落盘GitHub 托管索引数据不出本地数据不出本地

这篇文章聚焦路线二——Claude Code 的零索引方案。下面从 Boris 的公开表态开始拆解。

为什么选 Grep 不建索引?Boris 自己怎么说

Claude Code 的创建者 Boris Cherny 在多处公开聊过这个选择。关键信息:

他们先做了 RAG,后来才放弃的。

"Early versions of Claude Code used RAG + a local vector db, but we found pretty quickly that agentic search generally works better." —— Boris Cherny on X

在 Latent Space 播客里他说得更直白:

"We tried RAG… we tried a few different kinds of search tools. And eventually, we landed on just agentic search as the way to do stuff. One: it outperformed everything. By a lot. And this was surprising."

在 Pragmatic Engineer 采访里直接给了结论:

"Plain glob and grep, driven by the model, beat everything."

他给出的四个理由:

  1. 精确createD1HttpClient 要么在文件里,要么不在。语义检索返回的"概念相似但文本不匹配"的结果在代码场景下通常是噪音
  2. 简单。没有索引要建、维护、同步
  3. 新鲜。索引从建好的那一刻起就开始过时,文件系统读取永远是当前状态
  4. 隐私。索引存在哪?如果是第三方,就有安全隐患。Anthropic 自己代码库都不想上传

还有个有趣的细节——Boris 在 Meta 时观察到 Instagram 工程师在 IDE 的"跳转到定义"功能崩了之后,全部回退到了手动 Grep。这让他意识到 Grep 在代码搜索场景里天然就很好用。

LLM 驱动的多轮搜索循环

搞清楚立场之后,看这套机制是怎么转起来的。

核心逻辑就一句话:**LLM 自己决定搜什么、用什么工具、搜到后要不要继续搜,直到信息够用了才停。**没有预设的搜索流程,没有固定的工具调用顺序。

具体流程:用户的提问和工具列表一起发给 LLM → LLM 返回要么是文本回答,要么是工具调用请求 → 如果是工具调用,系统执行并追加结果到对话历史 → 带着更新后的上下文再次调用 LLM → 循环直到 LLM 觉得够了、不再调工具为止。

这个过程里,LLM 可以在一次响应里同时调多个工具,可以随时切换工具——没有硬编码的"必须先 Grep 再 Read"。跟 Cursor 那种"一次性检索、拼接 prompt、推理回答"的固定流程有本质区别。

还有一个关键角色:Explore 子代理。它不是直接搜文件,而是启动一个独立的子代理在隔离的上下文窗口里自己完成一整套搜索任务,最后只把总结结论返回给主对话。Explore 被设计为只读模式——disallowedTools 里排除了 FileEdit、FileWrite、NotebookEdit 等所有编辑类工具,系统提示词也明确禁止文件创建、修改、删除和任何改变系统状态的命令。它可以用 Glob、Grep、Read 以及受限制的 Bash(仅 ls、git status、cat、head、tail 等只读操作)。大量中间搜索结果在子代理自己的上下文里消化,主对话只收到精简结论,不会被撑满。

三个工具各自在什么时候触发

在拆开每个工具之前,先看看 LLM 在什么场景下会调用哪个工具:

工具什么时候触发典型调用
Glob不确定文件在哪,先按文件名模式搜一下Glob "src/**/redirect*"
Grep知道要搜什么关键词,但不知道在哪个文件里Grep "function handleRedirect"
Read已经知道具体文件和行号,需要精读代码Read "src/router.ts" offset=42 limit=50

三者的关系不是流水线——不是说必须先 Glob 再 Grep 再 Read。LLM 根据自己知道什么来决定:

  • 连文件名都不知道 → Glob 先探路
  • 知道要搜什么函数/变量名 → 直接 Grep,跳过 Glob
  • 已经知道文件路径(比如从 git diff 看到的)→ 直接 Read
  • 搜出来的片段够用了 → 就不调 Read 了,省一轮

Explore 子代理是第四层——当任务需要跨目录深挖时,主模型把它派出去独立搜索,自己不参与中间过程,只接收结论。

Grep:底层不是 GNU grep,是 ripgrep

后面的分析会反复出现几个文件名,先认识一下。假设有个简单的前端项目,跑起来之后发现登录跳转逻辑不对,想去代码里定位问题:

my-app/
├── src/
│   ├── pages/
│   │   └── Login.tsx           # 登录页
│   ├── services/
│   │   └── auth.ts             # 认证服务,含 refreshToken 方法
│   ├── utils/
│   │   └── redirect.ts         # 页面跳转工具函数
│   └── router.ts               # 路由配置,含 handleRedirect
├── tests/
│   └── auth.test.ts
└── node_modules/               # 依赖(.gitignore 排除)

后面提到的 router.tsredirect.tshandleRedirect 都在这个结构里。记个印象就行。

很多人以为 LLM 调的是系统自带的 grep。不是。

Claude Code 的 GrepTool 底层调的是 ripgrep(命令行里叫 rg),一个 2016 年用 Rust 重写的现代搜索工具。源码里很清楚:

// src/tools/GrepTool/GrepTool.ts
import { ripGrep } from '../../utils/ripgrep.js'

为什么不用 GNU grep?几个硬伤:

  • 不递归。默认只搜当前目录,得加 -r。Linux 和 macOS 上行为还不一样
  • 不认 .gitignore。node_modules、vendor、build output 全给你搜一遍
  • 大小写。默认区分大小写,搜 "schema" 找不到 "Schema",得加 -i。LLM 经常忘
  • 命令复杂。排除目录要拼长长的 --exclude-dir=node_modules --exclude-dir=dist ...

ripgrep 把这些问题全解决了:默认递归、默认尊重 .gitignore、自动跳过二进制、多线程并行、SIMD 加速。

性能差距有多大?

Claude Code 自己的 ~4500 文件、约 95 万行代码的实测:

搜索模式ripgrepGNU grep -r倍数
TOOL_VERBS(低频词)0.09s2.55s28x
async.*generator(正则)0.10s3.30s33x
import.*from(高频词)0.10s2.45s25x

比 GNU grep 快 25-33 倍。文件搜索范围几乎一样(rg 搜 4494 个文件,grep 搜 4522 个),差距主要来自 ripgrep 的多线程并行和 SIMD 向量化。0.1 秒左右的搜索延迟,对交互式多轮循环来说基本无感——这正是多轮 Grep 能成立的核心前提。

ripgrep 五层过滤:从几万文件到几个文件

ripgrep 不是对每个文件都做正则匹配,它在真正开始匹配之前,通过五层过滤逐步缩小范围——.gitignore 剪枝 → path 范围限制 → glob 文件类型过滤 → 二进制检测 → 最后才正则匹配。以搜 src/services/ 目录下的 refreshToken 为例,path: "src/services/" 把搜索范围从几千个文件直接砍到几十个,rg 只对这几个文件做正则匹配。在包含 node_modules/ 的完整项目里,.gitignore 剪枝的效果更明显,能一把砍掉几万个无关文件。

过滤之后 ripgrep 还做了文件内匹配优化:SIMD 向量化一次比较 32 字节、Boyer-Moore 跳跃算法、多线程并行处理——这些加起来让 ripgrep 比 GNU grep 快 25-33 倍。

源码怎么调 ripgrep

三种兜底策略确保 ripgrep 在任何环境下都能跑:

// src/utils/ripgrep.ts
function getRipgrepConfig() {
  // 1. 用系统的 rg
  if (userWantsSystemRipgrep) {
    const { cmd } = findExecutable('rg', [])
    if (cmd !== 'rg') return { mode: 'system', command: 'rg', args: [] }
  }

  // 2. Bun 打包模式:自己就是 rg
  if (isInBundledMode()) {
    return { mode: 'embedded', command: process.execPath,
      args: ['--no-config'], argv0: 'rg' }
  }

  // 3. 用自带的 vendor ripgrep
  const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
  const command = platform === 'win32'
    ? path.resolve(rgRoot, `${arch}-win32`, 'rg.exe')
    : path.resolve(rgRoot, `${arch}-${platform}`, 'rg')
  return { mode: 'builtin', command, args: [] }
}

核心调用:

ripGrep(args, target, signal)
    ↓
1. codesignRipgrepIfNecessary()   // macOS 签名
2. execFile(rgPath, [...args, target])  // 启动子进程
3. stdout → split('\n') → strip '\r' → filter empty
4. 返回 string[]

错误处理也很细:exit code 1 = 没匹配结果(正常返回空数组);EAGAIN 就重试加 -j 1 限制单线程;SIGTERM 尽量返回部分结果;30 秒超时;20MB stdout 上限防大仓撑爆内存。

参数拼装

GrepTool 的 call() 方法负责参数转译——自动拼接 --hidden--glob '!.git'--max-columns 500 等常用标记,然后把用户的 output_modehead_limit-C 上下文行数等参数转成对应的 ripgrep flag。最终底层命令大概是这样:

rg --hidden --glob '!.git' --glob '!.svn' \
   --max-columns 500 -n -C 3 \
   --glob '**/*.ts' "handleRedirect" /Users/me/project

三种输出模式

GrepTool 支持三种输出模式,核心目的是控制每次返回的信息量,避免上下文臃肿:

模式返回内容适用场景
files_with_matches(默认)仅文件名列表快速定位涉及的文件
content匹配行 + 行号 + 上下文直接看匹配的代码片段
count文件 + 匹配次数评估关键词在项目中的分布密度

默认 files_with_matches 是有意为之——搜到文件名后 LLM 自己判断哪些值得打开精读,而不是一把把所有代码灌进上下文。还有个保护机制:head_limit 默认 250,搜到 10000 条也只返回前 250 条。

Read:读文件的两条路

搜到文件后读内容。输入很简单:

Read "src/utils.ts" offset=120 limit=50

readFileInRange() 分了快慢两条路:

快路径:文件 < 10MB

const stats = await fsStat(filePath)

if (stats.isFile() && stats.size < 10 * 1024 * 1024) {
  // 10MB 以内:readFile + 内存 split,~2x 速度
  const text = await readFile(filePath, { encoding: 'utf8', signal })
  return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
}

readFileInRangeFast() 全程零 copy、零正则:去 BOM → 逐字符 indexOf('\n') 扫描 → 按 offset 切片 → 去 \r → join('\n') 返回。99% 的代码文件都小于 10MB,几乎每次都走这条路径。

慢路径:大文件或管道

文件超过 10MB 走流式路径。关键设计:只保留范围内的行,范围外的只计数不存内存。

createReadStream → 每 chunk 按 \n 拆行
  → 在 [offset, offset+maxLines] 内 → 存 selectedLines[]
  → 在范围外 → lineIndex++,丢弃
  → 流结束 → selectedLines.join('\n') 返回

所以读第 1 行跟读第 10 万行内存一样大——都只保留 maxLines 行。

去重缓存

同一个文件、同一个 offset 不重复读。用 readFileState 缓存已读信息。AI 有时会重复尝试读同一个文件,缓存直接返回 "File unchanged",省 I/O 和省 token。

Glob:找文件的名字

三个工具里最简单的——按文件名模式匹配:

const { files, truncated } = await glob(
  input.pattern,           // "src/**/redirect*"
  GlobTool.getPath(input), // 绝对路径
  { limit: 100, offset: 0 },
  abortController.signal,
  appState.toolPermissionContext,
)
const filenames = files.map(toRelativePath)  // 转相对路径省 token

最多 100 个文件,超了截断提示。Glob 是三个工具中 token 开销最低的——只返回路径列表,不返回任何文件内容。

三个工具如何协作:一张流程图看清全貌

用 mermaid 把 Grep、Read、Glob 的协作逻辑画出来。注意每一步返回的信息量和 LLM 的决策分支:

graph TB
    USER["👤 LLM 收到任务:<br/>'找项目中所有处理用户重定向的代码'"]

    USER --> Q1{"知道具体<br/>文件路径?"}
    Q1 -->|"否"| GLOB["🌐 Glob<br/>'src/**/redirect*'<br/><br/>返回: 3 个候选文件路径<br/>token 开销: ~零"]
    Q1 -->|"是"| GREP1["🔍 Grep<br/>'function.*redirect'<br/>output_mode=content -C=3<br/><br/>返回: 匹配行 + 上下文<br/>token 开销: 轻量"]

    GLOB --> Q2{"文件列表<br/>够精准了?"}
    Q2 -->|"不够,需要看内容"| GREP2["🔍 Grep<br/>output_mode=content<br/><br/>返回: 匹配行 + 行号<br/>列出所有涉及 redirect 的位置"]
    Q2 -->|"够了"| Q3{"需要精读<br/>具体实现?"}

    GREP1 --> Q3
    GREP2 --> Q3

    Q3 -->|"是"| READ["📖 Read<br/>'src/services/auth.ts'<br/>offset=120 limit=50<br/><br/>返回: cat -n 格式代码<br/>token 开销: 重<br/>(500-5000 tokens/文件)"]
    Q3 -->|"否,片段就够了"| DONE["✅ 信息足够,生成回答"]

    READ --> Q4{"还需要继续<br/>读后面的代码?"}
    Q4 -->|"是"| READ2["📖 Read<br/>同一文件 offset=80<br/><br/>继续读后半部分"]
    Q4 -->|"否"| DONE

    READ2 --> DONE

    style GLOB fill:#e8f5e9
    style GREP1 fill:#fff3e0
    style GREP2 fill:#fff3e0
    style READ fill:#fce4ec
    style READ2 fill:#fce4ec
    style DONE fill:#a5d6a7

三个工具的设计上有清晰的 token 梯度:Glob(~零)→ Grep(轻量)→ Read(重)。LLM 按这个梯度从轻到重逐级深入,Glob 和 Grep 帮 Read 省了大量无效读取。整个过程 LLM 自主决定每一步——搜到什么、要不要继续、用哪个工具——没有被硬编码的固定流程。

行业对比:三种不同的代码搜索架构

把 Claude Code 放进行业坐标系里看,会更清楚它的位置:

维度Claude CodeCodexCursor
索引语义索引 + trigram 倒排索引
搜索工具专用工具(GrepTool/Read/Glob)通过 shell 调 rg/find/catGrep + codebase_search
智能体搜索LLM 自主多轮循环LLM 自主多轮循环一次性检索为主
子代理Explore 等(上下文隔离)spawn_agent
语义搜索无(靠 LLM 翻译语义为关键词)向量检索(Turbopuffer)
启动延迟需建索引

Codex 和 Claude Code 做了相同的零索引选择,但对搜索工具的封装方式不同。Codex 没有专用搜索工具,就一个 shell 工具让 LLM 直接写 rg 命令——给了模型最大灵活性,但也意味着模型要自己处理非结构化的文本输出。Claude Code 把 Grep 封装成带结构化参数的专用工具,降低了 LLM 的使用难度。

Codex 多个 system prompt 文件里有同一条指令:

"When searching for text or files, prefer using rg or rg --files respectively because rg is much faster than alternatives like grep."

两个互为竞争对手的产品独立做出了几乎相同的架构决策。这不是巧合。

争议:Grep 真的省 token 吗?

零索引方案最受争议的点就是 token 开销。向量数据库厂商 Milvus(Zilliz)发过一篇文章直接开火:

"Grep is a dead end that drowns you in irrelevant matches, burns tokens, and stalls your workflow."

他们做了实测:调试一个 VSCode 扩展的 bug,Grep 反复搜索倾倒了大量无关文本,最终花了 14 次工具调用、32.2k tokens、59.3 秒才找到答案,正确的 10 行代码被埋在 500 行噪声里。他们开源了基于向量检索的 MCP 插件,声称相同任务上 token 消耗降 40%、工具调用降 36%

这个批评有道理,但也有几个前提需要理清:

**1. Milvus 卖向量数据库。**商业利益是透明的,但不代表技术批评错了。

2. Claude Code 有三层 token 控制机制:

  • Prompt Cache:LMCache 分析显示,Claude Code 的 agentic 循环里 92% 的 prompt 前缀在相邻轮次完全相同。Anthropic 的 prompt cache 对这些重复前缀按约 1/10 价格计费,实测 token 成本降低约 81%
  • Auto-Compaction:当上下文接近窗口上限时,自动对旧对话历史做摘要,替换原始消息,上下文不会无限增长
  • 子代理隔离:Explore 子代理在独立上下文里消化大量搜索中间结果,只把精简结论返回主对话

这三层机制让多轮 Grep 在实践中变得可控,但没有消除 Grep 方案相对 embedding 方案在单次检索精准度上的固有差距。Grep 的核心权衡是:用更多搜索轮次和更大上下文开销,换零索引、零维护、零启动延迟的工程简洁性。

3. Grep 在代码场景下有先天优势:

一篇 ISSTA '26 的学术论文(GrepRAG)在 CrossCodeEval 和 RepoEval_Updated 两个标准基准上做了实验。结果:即使是最朴素的单轮 Grep 检索,代码补全效果也超过了基于 embedding 的 RAG 基线——RepoEval_Updated Python Line 补全任务上,Grep 的 Exact Match 是 38.61%,而 embedding-based RAG 只有 24.99%。

论文解释了这个现象:代码搜索的关键词,95% 是标识符(类名 36%、方法名 41%、变量名 18%)。这些标识符本身就是代码的语义,精确匹配恰好是最直接有效的检索方式。getUserById 就是 getUserById,不会被改写成 fetchPersonByIdentifier——自然语言搜索中"词汇不匹配"是常态,在代码里恰恰相反,精确匹配刚好能发挥最大作用。

为什么不用 LSP

Claude Code 有完整的 LSP 支持——LSPTool 实现了 goToDefinition、findReferences、hover、callHierarchy 全套。不过在代码搜索场景下,Grep 通常是更好的第一选择:

速度:LSP 建连接 + 打开文件 + 构建 AST,100ms 起步。ripgrep 只要 10-50ms。

覆盖:LSP 只对配了语言服务器的语言生效。Grep 对所有文本文件都行——.ts、.py、.go、.md、.json、Dockerfile 全覆盖。

不过日常对话里 LSP 能省 token。一个 findReferences 就出精确引用列表,Grep 方式要搜一轮加读几个文件。

手搓一个简化版 MCP 工具

看完实现,自己写一个简化版,让 Cursor、Copilot 等任何支持 MCP 的工具都能用。

核心就三个函数,包装成 MCP server 注册即可。这里整理一个简单实现的框架伪代码:

// code-explorer MCP server 框架

server = MCP.Server("code-explorer")

// ① code_grep — 底层调 rg,把结果裁剪后返回
server.tool("code_grep", {
  pattern: string,        // 搜索正则
  path?: string,          // 限定目录,默认当前项目
  glob?: string,          // 文件类型过滤,如 "**/*.ts"
  output_mode?: "files_with_matches" | "content" | "count",
  head_limit?: number,    // 默认 100,防结果洪水
})
body:
  args = ["--hidden", "--glob", "!.git", "--max-columns", "500"]
  + 按 output_mode 拼 -l / -c / -n
  + 拼 glob、type 等可选参数
  → subprocess.run(["rg"] + args, timeout=30)
  → 按 head_limit 截断 → 返回文本

// ② code_read — 按行范围读文件,cat -n 格式输出
server.tool("code_read", {
  file_path: string,
  offset?: number,    // 默认 1
  limit?: number,     // 默认 200
})
body:
  → open(file_path) → readlines()
  → 切片 [offset-1 : offset-1+limit]
  → 每行加行号前缀 → 返回 "文件: ... 行数: ...\n  <行号>\t<内容>"

// ③ code_glob — 按文件名模式匹配
server.tool("code_glob", {
  pattern: string,    // 如 "src/**/redirect*"
  path?: string,      // 默认当前项目
})
body:
  → os.walk(path) → fnmatch.filter(pattern)
  → 最多 100 条,超了截断提示

// 启动
stdio.run(server)

使用效果

你: 找一下项目里所有处理 JWT token 的代码
AI: [调用 code_grep pattern="jwt.*token|token.*jwt" glob="**/*.ts"]

你: 看看 auth.tstoken 刷新部分
AI: [调用 code_grep pattern="refreshToken" path="src/services" -C=5]
    [调用 code_read file_path="src/services/auth.ts" offset=120 limit=50]

跟原版的差距:没有快慢路径切换、没有去重缓存、没有权限系统——但给 AI 配上搜索+读取+文件名匹配三个基本能力,已经够用了。

总结

回到原点:Claude code 怎么"看到"代码?答案不是建索引,不是向量化——就是像人一样,用搜索、定位、精读。

几个值得关注的结论:

  1. ripgrep 是共识选择。Claude Code 和 Codex 独立做出了相同的架构决策——零索引、LLM 驱动 ripgrep。这不是巧合
  2. 工具探索 ≠ 低级。GrepRAG 论文证明,即使最朴素的 Grep 检索,代码补全效果也超过 embedding-based RAG。代码标识符本身就是语义,精确匹配恰好是最有效的
  3. 工具设计省 token 比想象的重要。三种输出模式、head_limit=250、相对路径、截断提示——这些细节累积起来大幅影响上下文效率
  4. LSP 是补充不是替代。ripgrep 解决 80% 的场景,LSP 覆盖剩余的语义搜索需求
  5. 自己做不难。一个 MCP server + 三个函数,几十行伪代码的框架,任何支持 MCP 的工具都能用

参考