芥末
发布于 2026-04-30 / 0 阅读
0
0

Agent 代码搜索为什么回到 Grep:Claude Code 零索引架构解析

RAG(Retrieval-Augmented Generation,检索增强生成)曾经是让大语言模型理解外部知识的默认方案:先把文档或代码切块,生成 embedding,写入向量数据库;用户提问时,再从向量库里找相似片段,塞进上下文交给模型回答。

但在 AI 编程工具里,事情开始变得不一样。Claude Code 和 Codex CLI 这类 Agent 编程工具,核心代码搜索并不依赖 embedding,也不依赖向量数据库,而是把搜索任务交给 LLM(Large Language Model,大语言模型)自己规划,再通过 Grep、Glob、Read 等工具一轮轮探索代码库。

这不是简单地“回到命令行搜索”,而是搜索范式发生了变化:

  • 传统 RAG 是预先检索:系统先猜模型可能需要哪些上下文。
  • Agent 搜索是按需探索:模型根据当前问题,决定搜什么、读什么、要不要继续追踪。

在代码库这种数据形态里,标识符、文件路径、函数名、类名本身就携带大量语义。getUserById 通常不会被改写成一段自然语言描述,精确匹配反而非常可靠。再加上现代 ripgrep 的速度足够快,零索引搜索在本地项目里有了很强的工程吸引力。

Claude Code 的核心搜索循环

Claude Code 的搜索机制可以抽象成一个工具调用循环:模型拿到用户问题和可用工具列表后,决定要不要调用工具;工具执行结果再追加到对话历史里,模型基于新的上下文继续决策,直到信息足够为止。

flowchart TD
    A[用户提出问题] --> B[把问题、历史上下文、工具列表交给 LLM]
    B --> C{LLM 是否调用工具}
    C -- 否 --> D[直接生成回答]
    C -- 是 --> E[执行工具调用]
    E --> F[把工具结果追加到对话历史]
    F --> B

    C -. 退出条件 .-> G[达到轮次上限 / 预算限制 / 用户中断 / 权限拒绝]

这个循环没有写死“必须先 Grep 再 Read”。模型可以直接读取已知文件,也可以同时发起多个搜索请求,还可以调用子 Agent 做更大范围的探索。系统只是通过工具设计和提示词引导模型选择合理路径,而不是把搜索流程硬编码死。

四类基础搜索工具

Claude Code 的代码探索主要依赖这几类工具:

工具底层能力典型用途
GrepToolripgrep / rg按正则搜索文件内容
GlobToolglob 路径匹配按文件名、目录、扩展名找文件
FileReadTool文件系统读取读取指定文件的指定行范围
AgentTool启动独立子 Agent把大范围搜索任务交给隔离上下文处理

还有一类重要补充是 LSP(Language Server Protocol,语言服务器协议)工具,例如 “go to definition” 和 “find references”。LSP 更适合处理语义精确的代码跳转,能减少 Grep 后再读多文件的次数。

子 Agent 的价值:隔离上下文

AgentTool 并不是简单的搜索命令,而是启动一个独立对话环境。比如 Explore 类型的子 Agent 只具备搜索和读取能力,不能编辑文件,也不能执行危险命令。它可以在自己的上下文窗口里完成多轮 Grep、Glob、Read,最后只把整理后的结论返回主对话。

这种设计解决了一个很现实的问题:搜索过程会产生大量中间结果。如果所有 Grep 输出和代码片段都塞进主对话,主上下文很快会被噪声占满。子 Agent 相当于一个临时研究员,消化完材料后只交付结论。

flowchart LR
    Main[主对话] --> Task[分派搜索任务]
    Task --> Sub[Explore 子 Agent]
    Sub --> G[Grep / Glob]
    Sub --> R[Read 文件片段]
    G --> Sub
    R --> Sub
    Sub --> Summary[压缩后的结论]
    Summary --> Main

GrepTool 如何控制返回信息量

直接把所有匹配代码都返回给模型,很容易烧掉大量 token。Claude Code 的 GrepTool 通过输出模式控制信息密度,让模型按需选择“只要路径”还是“要代码片段”。

输出模式返回内容适合场景后续动作
files_with_matches只返回命中文件路径先定位相关文件通常再调用 Read
content返回匹配行及上下文确认常量、函数签名、局部逻辑不一定需要 Read
count返回每个文件的命中次数判断关键词分布密度帮助缩小搜索范围

默认模式是 files_with_matches,只返回路径,不返回代码内容。这个选择很关键:一次宽泛搜索可能命中几百个文件,如果默认倾倒匹配行,模型上下文会立刻被污染。只返回文件名能让模型自己判断哪些文件值得深入读取。

例如:

Grep({
  pattern: "class.*Transport",
  output_mode: "files_with_matches"
})

可能只返回:

cli/transports/WebSocketTransport.ts
cli/transports/SSETransport.ts

模型看到路径后,再选择读取最相关的文件:

Read({
  file_path: "cli/transports/WebSocketTransport.ts",
  offset: 1,
  limit: 120
})

如果只是想确认一个常量怎么定义,content 模式就足够:

Grep({
  pattern: "TOOL_VERBS",
  path: "bridge/sessionRunner.ts",
  output_mode: "content",
  "-C": 5
})

这里 -C: 5 表示返回匹配行前后各 5 行。很多时候,局部上下文已经能回答问题,不需要读取整个文件。

一个完整例子:追踪 GrepTool 的执行记录

假设要弄清楚一个问题:当 LLM 调用 GrepTool 搜索代码时,bridge 系统如何追踪并记录这次工具调用?

这个问题的答案不在单个文件里,需要从工具调用、session 输出、活动解析、UI 渲染几条链路拼起来。Agent 搜索的过程大致会这样展开。

第 1 轮:用宽泛关键词定位文件

模型会把问题拆成几个可能出现的代码关键词,例如:

  • GrepTool
  • tool.*track
  • tool.*activity

然后在 TypeScript 文件里搜索:

Grep({
  pattern: "GrepTool|tool.*track|tool.*activity",
  glob: "*.ts"
})

返回的可能是这些文件:

structuredIO.ts
sessionRunner.ts
bridgeUI.ts
bridgeStatusUtil.ts

其中多个文件位于 bridge/ 目录,而问题关注 bridge 系统。sessionRunner.ts 从命名上看也很关键:它可能负责启动 session、读取 session 输出、处理工具执行事件。

第 2 轮:查看关键文件中的局部上下文

模型继续搜索 sessionRunner.ts 里的相关片段:

Grep({
  pattern: "GrepTool|tool.*activity",
  path: "bridge/sessionRunner.ts",
  output_mode: "content",
  "-C": 5
})

局部代码里能看到类似“工具名到动词”的映射,例如:

const TOOL_VERBS = {
  Grep: 'Searching',
  GrepTool: 'Searching',
  Glob: 'Searching',
  GlobTool: 'Searching',
  Read: 'Reading',
  FileReadTool: 'Reading',
  Edit: 'Editing',
  FileEditTool: 'Editing',
  Bash: 'Running',
  BashTool: 'Running',
}

这说明 GrepTool 被归类成 Searching 活动。工具名不是动态猜出来的,而是在映射表里显式维护。

第 3 轮:读取完整逻辑

局部片段只能看到映射表的一部分。模型会继续读取更大的行范围,找到摘要生成逻辑。

可以把核心流程理解成这样:

function summarizeToolCall(toolName: string, input: ToolInput) {
  const verb = TOOL_VERBS[toolName] ?? 'Using'

  const target =
    input.file_path ??
    input.pattern ??
    input.command ??
    input.url ??
    ''

  return `${verb} ${target}`
}

也就是说:

GrepTool({ pattern: "reconnect|backoff" })

会被整理成类似这样的活动摘要:

Searching reconnect|backoff

桥接系统并不需要理解 Grep 的完整语义,只要抽取工具名和关键参数,就能把后台行为变成用户可读的状态。

第 4 轮:追踪活动事件去向

知道活动事件怎么生成后,还要继续找它被谁消费。模型会搜索活动类型和当前活动字段:

Grep({
  pattern: "SessionActivity|currentActivity",
  path: "bridge/",
  output_mode: "content",
  "-C": 2
})

搜索结果会把几处关键代码串起来:

文件作用
bridge/types.ts定义活动事件结构,例如类型、摘要、时间戳
bridge/sessionRunner.ts从 session 输出中解析工具调用事件,并生成活动摘要
bridge/bridgeMain.ts周期性轮询每个 session 的当前活动,维护最近工具调用轨迹
bridge/bridgeUI.ts把活动摘要渲染到 bridge 状态面板

完整链路可以画成这样:

sequenceDiagram
    participant LLM as LLM
    participant Session as Session 进程
    participant Parser as 活动解析器
    participant Main as bridge 主进程
    participant UI as bridge UI

    LLM->>Session: 调用 GrepTool
    Session-->>Parser: stdout 输出工具调用 JSON
    Parser->>Parser: 提取工具名和输入参数
    Parser->>Parser: 生成摘要 Searching pattern
    Parser-->>Main: 更新 currentActivity
    Main->>Main: 维护最近活动轨迹
    Main-->>UI: 推送工具活动状态
    UI->>UI: 渲染 Searching / Reading / Editing

这个例子体现了 Agent 搜索和传统一次性检索的区别。每一步搜索词都来自上一轮发现:先找相关文件,再读局部上下文,再追踪类型引用,最后拼出跨文件调用链。这种探索路径很难通过预先向量检索一次猜中。

为什么暴力扫描代码库仍然够快

“每次都 Grep 整个项目”听起来很粗暴,但 Claude Code 调用的不是传统 GNU grep,而是 ripgrep,命令名通常是 rg

ripgrep 是面向大型代码库搜索设计的现代工具,默认遵守 .gitignore,会跳过二进制文件,支持多线程,并且在匹配层面使用 SIMD(Single Instruction Multiple Data,单指令多数据)等优化。它不是傻傻地把所有文件逐字节扫一遍。

ripgrep 的五层过滤

一次搜索真正进入正则匹配之前,会经过多层过滤:

flowchart TD
    A[项目全部文件] --> B[目录级剪枝: .gitignore]
    B --> C[path 限制: 只遍历指定目录]
    C --> D[glob 过滤: 只保留匹配文件名]
    D --> E[二进制检测: 跳过非文本文件]
    E --> F[内容匹配: 正则搜索]
    F --> G[返回命中文件或匹配片段]

以前面的 bridge 搜索为例:

Grep({
  pattern: "SessionActivity|currentActivity",
  path: "bridge/",
  glob: "*.ts"
})

过滤过程大致是:

阶段文件数量变化说明
原始文件4,471代码快照内的全部文件
.gitignore 剪枝4,471如果没有 node_modules,这一层变化不明显
path: bridge/32只遍历 bridge 目录
glob: *.ts32bridge 下基本都是 TypeScript 文件
二进制检测32文本文件继续进入匹配
正则匹配3只有 3 个文件命中

pathglob 的组合非常重要。Agent 多轮搜索时,模型会不断根据已知信息缩小范围:从全项目,到某个目录,再到某类文件,最后到某个文件的局部行号。

文件内匹配为什么快

ripgrep 在文件内容匹配上也做了多层优化:

优化作用
SIMD 向量化一条 CPU 指令并行比较多个字节,快速定位可能命中的位置
Boyer-Moore 跳跃固定字符串搜索时,遇到不匹配可跳过多个字符
Page Cache操作系统会把读过的文件缓存在内存中,重复搜索不必重新读磁盘
mmap 内存映射对大文件减少内核态到用户态的数据复制
多线程一个线程遍历目录,多个 worker 并行搜索不同文件

开发者日常处理的代码库通常会频繁被编辑器、语言服务器、构建工具访问,因此大量文件已经在操作系统 Page Cache 里。一次搜索更像是在内存里扫文本,而不是每次都从磁盘冷启动。

实测级别的差距

在一个约 4,500 个文件、95 万行代码的 TypeScript 代码库上,ripgrep 和 GNU grep -r 搜同样关键词,耗时差距很明显:

搜索模式ripgrepGNU grep -r差距
TOOL_VERBS 低频词0.09s2.55s约 28 倍
async.*generator 正则0.10s3.30s约 33 倍
import.*from 高频词0.10s2.45s约 25 倍

0.1 秒级别的搜索对交互式 Agent 来说基本可接受。真正慢的往往不是 rg 本身,而是模型推理、工具调度、网络请求和上下文处理。

数据规模决定可行性

零索引 Grep 成立的前提,是搜索对象通常是开发者本地代码库,而不是全互联网文档或公司级知识库。

维度向量检索暴力扫描Grep 暴力扫描
常见数据规模GB 到 TBMB 到几百 MB
单次比较高维向量相似度计算字节或字符串匹配
命中目标语义相似片段精确标识符、路径、文本
是否适合全量扫描通常不适合中小型代码库可行

假设一个代码库大小为 250MB,并且文件已经命中 Page Cache。按现代机器 30GB/s 左右的内存带宽估算,单纯把 250MB 数据从内存扫一遍的理论下界大约是:

250MB / 30GB/s ≈ 8ms

真实搜索还要加正则匹配、线程调度和结果整理,所以常见耗时会到几十毫秒或一百多毫秒。这个数量级不一定值得专门维护一套索引系统。

Cursor、Claude Code、Codex 的架构分歧

行业里并不是所有 AI 编程工具都放弃索引。Cursor 代表的是另一条路线:语义索引加精确搜索索引。

Cursor 的双索引路线

Cursor 的代码检索可以理解成两套系统叠加:

索引类型做法解决的问题
语义索引用 tree-sitter 按语法结构切块,生成 embedding,写入向量搜索引擎处理概念性查询、跨文件语义关联
精确搜索索引用 trigram(三字符组合)倒排索引加速 grep 类搜索快速定位字符串、符号、路径

trigram 索引的思路并不复杂。比如字符串 OAuth 可以切成:

OAu
Aut
uth

索引会记录每个 trigram 出现在哪些文件里。搜索时先取这些 trigram 对应文件集合的交集,得到候选文件,再对候选文件跑真正的正则匹配。

flowchart LR
    A[查询字符串 OAuth] --> B[拆成 trigram: OAu / Aut / uth]
    B --> C[查倒排索引]
    C --> D[求文件集合交集]
    D --> E[候选文件]
    E --> F[执行精确正则匹配]
    F --> G[返回结果]

Cursor 的路线是预处理:后台先切块、建索引、同步变化,检索时直接利用索引加速。Claude Code 的路线是按需搜索:不预处理、不维护索引,模型在对话中实时决定搜索动作。

两者不是简单的优劣关系,而是面向的规模和产品目标不同。

方案优点代价适合场景
零索引 + Grep无启动成本、无索引过期、实现简单、直接搜索最新文件多轮搜索可能增加 token,语义召回弱本地中小型代码库、精确符号搜索
向量索引能处理概念性查询,适合大规模代码库和跨仓库搜索需要切块、同步、存储、更新、权限管理大型代码库、团队级代码搜索、自然语言查询
trigram / 倒排索引精确搜索更快,适合超大文件集合需要预处理和增量维护大型代码库里的字符串搜索

Codex CLI 的相同选择

Codex CLI 和 Claude Code 在“是否建索引”上选择相近:不建向量索引,不依赖 embedding,搜索时优先使用 rg

两者不同点在工具封装层面:

方面Claude CodeCodex CLI
搜索入口专用 GrepToolGlobToolFileReadTool通过 shell 执行 rgfindcat 等命令
参数结构工具有明确 schema模型自己写 shell 命令
输出控制工具层可限制模式、行数、返回格式依赖命令组合和模型自控
灵活性较可控更接近真实终端,可组合管道
风险工具能力边界清晰shell 输出更非结构化,命令写错概率更高

Claude Code 把搜索能力封装成结构化工具,便于控制返回内容和权限边界。Codex CLI 则更像把一个熟悉 Unix 工具链的工程师放进终端,让模型直接组合 rgsedcatgit 等命令。

共同点更关键:两者都认为,对于开发者本地代码库,LLM 驱动 ripgrep 已经足够支撑大量代码理解任务。

Grep 方案的成本:token 会不会失控

Grep-only 搜索最常见的批评是 token 成本。多轮 Grep 和 Read 会把越来越多结果追加到对话历史里,而每轮调用模型时,历史上下文都要重新参与请求。搜索范围一旦过宽,模型可能读到大量无关代码,既慢又贵。

这个问题真实存在,不能用 “ripgrep 很快” 掩盖。rg 的本地搜索耗时可能只有 100ms,但如果返回 500 行噪声,后续模型处理这些文本的成本会远高于搜索命令本身。

Claude Code 主要靠三类机制控制上下文膨胀。

prompt cache:降低重复前缀成本

Agent 循环的请求有一个特点:相邻两轮输入大部分是相同的,只是在尾部追加了最新工具结果。

第 N 轮请求:
[system prompt][历史消息 A][历史消息 B][工具结果 N]

第 N+1 轮请求:
[system prompt][历史消息 A][历史消息 B][工具结果 N][工具结果 N+1]

如果模型服务支持 prompt cache,就可以复用相同前缀的计算结果。这样每轮不必按完整上下文重新付全价,重复部分按缓存策略计费或复用计算。

Claude Code 在工程上会把稳定的 system prompt 拆成多个块,尽量让不变部分命中缓存,避免被动态内容破坏缓存前缀。

auto-compaction:把旧历史压缩成摘要

当对话历史接近上下文窗口上限时,可以触发自动压缩:让模型把早期工具调用、搜索结果、阶段性结论压缩成摘要,再用摘要替换原始长消息。

flowchart LR
    A[长对话历史] --> B[触发压缩]
    B --> C[LLM 生成阶段摘要]
    C --> D[摘要替换旧消息]
    D --> E[释放上下文空间]

这会丢失一部分细节,但能保留搜索方向、关键文件、已确认结论。对于长任务来说,不压缩就会被上下文窗口硬性截断,压缩是更可控的折中。

子 Agent:把噪声留在隔离上下文里

前面提到的 Explore 子 Agent 也是成本控制手段。主对话不直接接收每一轮搜索的原始输出,只接收子 Agent 的整理结果。这样主上下文更像“决策层”,子 Agent 的上下文则承担“资料消化层”。

flowchart TD
    A[主 Agent] --> B{任务是否需要大范围搜索}
    B -- 否 --> C[主 Agent 直接 Grep / Read]
    B -- 是 --> D[启动 Explore 子 Agent]
    D --> E[子 Agent 多轮搜索]
    E --> F[子 Agent 总结关键结论]
    F --> A

这些机制只能缓解 token 成本,不能消除 Grep 在语义召回上的弱点。Grep 方案的本质取舍是:用更多交互轮次和上下文管理,换取零索引、零维护、实时读取工作区文件。

Grep 为什么特别适合代码搜索

自然语言搜索和代码搜索有一个关键差异:自然语言表达经常换词,代码则大量依赖稳定标识符。

在自然语言里,用户问“如何撤销订单”,文档可能写的是“取消交易流程”。如果只做字面匹配,很可能搜不到。embedding 擅长处理这种词汇不一致问题。

代码里就不一样了。函数名、类名、变量名、配置项、错误码、文件路径都是强信号:

getUserById
SessionActivity
GrepTool
bridgeUI.ts
TOOL_VERBS

这些标识符通常不会被同义改写。只要模型能把问题转换成合适的关键词,Grep 就能非常直接地命中相关代码。

代码搜索里的常见问题也天然适合精确匹配:

问题类型典型搜索词
某个函数在哪里定义函数名
某个类被谁引用类名
某个配置在哪里生效配置 key
某个日志从哪里打印日志文本
某个错误如何抛出错误码或错误消息
某条链路如何串起来类型名、事件名、方法名

LLM 的价值在于把模糊问题改写成一串可能命中的代码符号。例如“bridge 怎么显示工具正在搜索”可以被拆成:

bridge
tool activity
currentActivity
GrepTool
Searching
SessionActivity

这一步是传统手写 grep 的痛点,也是 Agent 搜索比人工搜索更强的地方。人需要反复猜关键词,LLM 可以基于中间结果持续改写查询。

Grep 的边界在哪里

零索引 Grep 并不适合所有场景。判断是否该用它,可以看两个因素:数据形态和数据规模。

适合 Grep 的场景

场景原因
本地中小型代码库ripgrep 扫描足够快
精确符号定位标识符、路径、错误码适合字面匹配
文件实时变化频繁不建索引就没有同步和过期问题
Agent 需要多轮探索每轮可根据新发现调整关键词
权限和隐私要求高不必把代码切块上传到索引服务

不适合只靠 Grep 的场景

场景问题
大型 monorepo全量扫描延迟可能不可接受
跨仓库知识检索本地 Grep 搜不到远端或历史知识
自然语言文档问答同义改写多,字面匹配召回弱
概念性代码理解不知道符号名时,关键词难生成
需要稳定高召回向量、BM25、图检索或混合检索更稳

BM25 是一种基于词项匹配的排序算法,常用于搜索引擎。实际工程里,很多系统会采用混合检索:Grep / BM25 处理精确匹配,embedding 处理语义相似,再用 rerank 或 rank fusion 合并结果。

RAG 没死,死的是“代码搜索必须先建向量索引”的默认假设

把 Claude Code、Codex CLI 和 Cursor 放在一起看,可以得到一个更准确的结论:

  • RAG 作为“让模型使用外部信息”的思想仍然成立。
  • embedding 向量库不是代码搜索的唯一入口。
  • 在本地代码库里,LLM 驱动 ripgrep 往往足够快、足够直接。
  • 当仓库规模变大,或者查询从代码符号变成自然语言概念,索引仍然有价值。
  • 更现实的方向不是 Grep 和 RAG 二选一,而是按数据规模、查询类型、成本约束组合使用。

Agent 时代的检索重点,不再只是“提前把知识切好块放进库里”,而是让模型能主动提出更好的问题、选择更合适的工具、根据反馈继续探索。对于代码搜索来说,Grep 的回归并不是倒退,而是 LLM 把一个老工具变成了可迭代推理流程的一部分。


评论