芥末
发布于 2025-09-16 / 0 阅读
0
0

Claude Code 为什么选择 grep 而不是代码索引:无状态设计的工程取舍

AI 编程助手做代码理解时,常见做法是先建立索引:把代码切块、解析符号、生成向量,查询时再从索引里召回相关片段。Cursor、JetBrains 系 IDE(Integrated Development Environment,集成开发环境)都大量依赖这类机制。

Claude Code 的路线看起来不一样。公开资料中,Anthropic 团队把它称为 agentic search,也就是让模型像开发者在终端里排查问题一样,主动调用 globgrep 等工具,一轮轮缩小搜索范围,而不是强依赖一个长期维护的代码索引。

这不是简单的“老工具打败新技术”。更准确地说,Claude Code 选择的是一种更接近 Unix 工具链的无状态(stateless)工作方式:每次搜索都以当前文件系统为准,工具本身不需要记住过去发生了什么。

flowchart LR
    Q[用户问题] --> A[AI 编程助手]

    A --> B{代码理解路线}

    B --> C[索引路线]
    C --> C1[代码切块]
    C1 --> C2[生成向量或符号索引]
    C2 --> C3[(索引库)]
    C3 --> C4[查询时召回相关片段]
    C4 --> A

    B --> D[实时搜索路线]
    D --> D1[glob 找文件]
    D1 --> D2[grep 搜内容]
    D2 --> D3[读取少量上下文]
    D3 --> A

两条路线都能工作,区别在于状态放在哪里、由谁维护、失败时会产生什么代价。

状态到底是什么

状态不是“有没有数据”这么简单。状态指的是:系统过去保存的信息,会不会影响未来同样输入下的输出。

一个无状态函数只依赖当前输入:

Output = f(Input)

一个有状态系统还依赖历史或内部存储:

Output = f(Input, History)

用代码看更直观。

# 有状态:结果依赖 counter 之前被调用过几次
class Counter:
    def __init__(self):
        self.value = 0

    def next(self):
        self.value += 1
        return self.value


# 无状态:相同输入永远得到相同输出
def add(a, b):
    return a + b

Counter.next() 第一次返回 1,第二次返回 2。它必须记住历史。

add(2, 3) 无论调用多少次都返回 5。它不关心之前发生过什么。

场景更接近有状态还是无状态原因
银行账户余额有状态当前余额是所有历史交易的累积结果
文本大小写转换无状态输入 "hello",输出总是 "HELLO"
购物车有状态用户之前加入的商品必须保留
grep 搜索文件内容无状态给定文件和模式,结果只取决于当前文件内容
代码索引有状态索引是从代码派生出的缓存,需要构建、刷新和失效处理

代码索引本质上是一种派生状态。源代码才是真相,索引是为了查询更快、更智能而预先构建出来的辅助数据。只要有派生状态,就会出现同步问题:代码变了,索引有没有更新?分支切换了,索引有没有失效?忽略规则变了,索引有没有重建?

Unix 管道把无状态思想做成了工程工具

grep 诞生于 1973 年,是 Unix 生态里最典型的文本搜索工具之一。它的强大不只在搜索本身,还在于它能和其他小工具稳定组合。

比如要从日志里找出最常见的 10 类错误,可以这样写:

cat app.log \
  | grep "ERROR" \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -10

每个工具都只做一件事:

工具职责是否需要知道上下游
cat输出文件内容不需要
grep按模式过滤文本不需要
sort排序不需要
uniq -c合并相邻重复行并计数不需要
head截取前几行不需要

管道能成立,是因为这些工具不共享内部状态。grep 不需要知道数据来自日志文件、网络流还是另一个程序;head 也不关心前面经历过几轮处理。

flowchart LR
    A[app.log] --> B[cat]
    B --> C[grep ERROR]
    C --> D[sort]
    D --> E[uniq -c]
    E --> F[sort -rn]
    F --> G[head -10]

这种设计带来的不是单个工具的复杂能力,而是组合能力。今天要找错误 IP,明天要统计每个 IP 的次数,只需要替换管道里的几个环节。

# 找出错误日志中的所有 IP
cat app.log \
  | grep "ERROR" \
  | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
  | sort -u

# 统计每个 IP 出现的错误次数
cat app.log \
  | grep "ERROR" \
  | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
  | sort \
  | uniq -c \
  | sort -rn

无状态工具的价值就在这里:它不假设自己处在某个固定工作流里,所以能被放进很多工作流。

分布式系统为什么偏爱无状态

无状态不是 Unix 独有的思想。分布式系统里,它几乎是横向扩容的基础条件。

以 Web 服务为例,REST(Representational State Transfer,表述性状态转移)强调服务端不要把用户会话强绑定在某一台机器上。请求需要的上下文应由客户端携带,或者从共享存储中读取。

有状态会话的典型问题是:

flowchart LR
    U[用户] --> LB[负载均衡]
    LB --> S1[服务器 A<br/>保存用户会话]
    LB --> S2[服务器 B<br/>没有用户会话]

    S1 -.会话状态.-> S1

如果用户第一次请求落到服务器 A,A 记住了登录状态;第二次请求落到服务器 B,B 不知道这个用户是谁,就需要额外的会话同步、粘性会话或共享缓存。

无状态 API(Application Programming Interface,应用程序编程接口)会把必要信息放进请求里,例如 JWT(JSON Web Token):

flowchart LR
    U[用户携带 JWT 发起请求] --> LB[负载均衡]
    LB --> S1[服务器 A]
    LB --> S2[服务器 B]
    LB --> S3[服务器 C]

    S1 --> DB[(数据库)]
    S2 --> DB
    S3 --> DB

任意服务器都能处理请求,扩容就是增加服务器实例,缩容就是移除实例。服务器自身不持有关键会话状态,故障恢复也更直接。

Serverless 也是同样的思路。函数计算平台通常要求开发者把一次调用看成一次独立执行:函数可以被复制到很多实例上运行,平台负责调度和扩缩容。运行时可能复用容器来减少冷启动成本,但编程模型不能依赖本地内存、临时文件或某个固定实例长期存在。

这是一笔工程交易:少依赖运行环境里的状态,换取部署、扩容和失败恢复上的简单。

无状态设计的几个直接收益

可组合性更好

无状态组件不关心自己被谁调用,也不要求调用方处在某种历史阶段。只要输入格式匹配,就可以组合。

这也是命令行工具、函数式编程、数据处理框架常常强调纯函数和不可变数据的原因。纯函数没有隐藏依赖,放到哪里都更容易推理。

并行更自然

如果两个任务之间没有共享状态,它们就可以同时执行。

搜索代码库就是典型场景。多个文件之间通常没有搜索状态依赖,完全可以并行扫描。

flowchart TB
    P[搜索任务] --> F1[文件分片 1]
    P --> F2[文件分片 2]
    P --> F3[文件分片 3]
    P --> F4[文件分片 4]

    F1 --> R[合并匹配结果]
    F2 --> R
    F3 --> R
    F4 --> R

一旦搜索过程需要维护全局可变状态,比如全局排序、实时进度写入、共享缓存更新,就会引入锁、竞争条件和同步成本。并行仍然可以做,但工程复杂度会上升。

失败恢复更简单

有状态服务崩溃后,常见问题不是“重新启动进程”这么简单,而是要恢复内部状态:

  • 未提交事务如何处理?
  • 缓存和数据库是否一致?
  • 任务队列里是否有重复消费?
  • 索引是否损坏?
  • 连接池和后台线程是否正确释放?

无状态服务失败后,处理方式通常更直接:丢弃当前实例,让下一次请求在新实例上执行。关键状态交给数据库、对象存储、消息队列这类专门的有状态基础设施管理。

测试更稳定

无状态函数的测试更像验证数学公式。相同输入对应相同输出,测试失败时更容易定位到逻辑本身。

def normalize_path(path: str) -> str:
    return path.strip().replace("\\", "/")


def test_normalize_path():
    assert normalize_path("  src\\main.py ") == "src/main.py"

有状态系统测试时,环境污染更常见:数据库残留数据、全局变量未清理、缓存未失效、测试顺序互相影响。状态越多,测试越需要准备和清理环境。

代码索引是一种有用但昂贵的状态

AI 编程助手里的索引大致分两类。

一种是传统 IDE 索引。JetBrains 系 IDE 会解析代码,构建 PSI(Program Structure Interface,程序结构接口)、stub 索引、符号表等数据结构,用于跳转定义、查找引用、重构、类型分析。

另一种是向量索引。系统把代码切成片段,生成 embedding(嵌入向量),存入向量数据库。用户搜索“用户认证逻辑”时,即使代码里没有这个完整短语,也可能召回 loginauthenticateverifyUser 等相关实现。RAG(Retrieval-Augmented Generation,检索增强生成)常用这条路线。

路线核心做法优势代价
传统 IDE 索引解析语法树、符号、类型关系跳转、重构、类型检查非常强构建和维护成本高,语言生态适配复杂
向量索引代码切块后生成向量,按语义召回适合模糊需求和概念搜索可能召回不精确,索引会过期,常涉及远程存储
grep / glob 实时搜索按文件名和文本模式扫描当前代码结果确定、无需构建、天然本地依赖关键词,语义理解能力弱

索引并不是坏东西。问题在于,索引一旦成为核心路径,就必须处理派生状态的全部生命周期:

flowchart LR
    A[代码变更] --> B[检测变更]
    B --> C[重新解析或重新切块]
    C --> D[更新索引]
    D --> E[查询索引]
    E --> F{索引是否新鲜}
    F -->|是| G[返回结果]
    F -->|否| C

这条链路里任何环节出错,都会造成“代码里明明有,但搜索不到”“搜索结果指向旧位置”“切换分支后索引异常”等问题。

Claude Code 选择实时搜索,就是尽量避开这类派生状态。它每次都从当前工作区读取事实。

Claude Code 的 agentic search 是怎么工作的

把 Claude Code 的搜索方式理解成“模型驱动的终端排查”会更准确。它不是一次性把整个项目丢给模型,也不是只执行一个粗暴的全局 grep,而是根据问题不断调整搜索策略。

典型过程像这样:

sequenceDiagram
    participant User as 用户
    participant Agent as Claude Code
    participant FS as 文件系统

    User->>Agent: 这个接口的鉴权逻辑在哪里?
    Agent->>FS: glob 搜索路由、控制器、middleware 文件
    FS-->>Agent: 返回候选文件路径
    Agent->>FS: grep 搜索 auth、authenticate、token 等关键词
    FS-->>Agent: 返回匹配行和少量上下文
    Agent->>FS: 读取最相关的几个文件片段
    FS-->>Agent: 返回代码内容
    Agent-->>User: 解释鉴权调用链和关键位置

这个过程有几个关键点:

  1. glob 先缩小文件范围,例如只看 src/**/*.tsapp/**/middleware.*
  2. grep 再按关键词或正则表达式搜索内容。
  3. 工具返回匹配行和上下文,而不是把整个仓库塞进上下文窗口。
  4. 模型根据结果继续搜索,直到找到足够证据。

如果手工模拟,可以使用 rg(ripgrep,一种现代高性能 grep 类工具):

# 先按文件名缩小范围
find src -type f | grep -E '(auth|login|middleware|route)'

# 搜索认证相关关键词,并排除依赖目录
rg -n --glob '!node_modules' --glob '!dist' \
  'authenticate|authorization|jwt|login|verifyUser' src

# 查看匹配点附近上下文
rg -n -C 3 'function processPayment|class PaymentService' src

好的 agentic search 不会把“实时搜索”做成“全仓库乱扫”。它会像工程师排查问题一样,先猜测代码可能在什么目录,再用关键词验证,再打开少量文件确认调用关系。

为什么实时搜索在 AI 编程助手里有吸引力

零构建等待

索引路线通常需要初始化:扫描代码、切块、解析、上传或写入本地数据库。大型仓库里,这个过程可能持续一段时间。实时搜索没有这一步,工具拿到工作区就可以开始查。

这对命令行里的 AI 工具尤其重要。终端任务往往短平快,用户可能只是问一个具体问题、改一个小 bug、分析一段日志。为了几十秒的任务等待索引构建,体验会很割裂。

结果更确定

向量搜索的结果带有概率性质。搜索失败时,很难立刻判断原因:

  • 查询表达不适合?
  • 代码切块太碎或太大?
  • embedding 模型没有捕捉到语义?
  • 索引没有更新?
  • 召回数量太少?
  • 排序模型把关键片段排后面了?

grep 的失败原因更简单:当前文件里没有匹配这个模式,或者关键词选错了。它不理解语义,但行为可预测。

确定性对调试很重要。尤其是代码修改任务,模型需要引用真实文件位置和真实代码片段,模糊召回如果把相似但无关的实现拿来当依据,可能会生成错误修改。

隐私边界更清晰

向量索引常常涉及远程 embedding 服务或向量数据库。即使服务承诺不保存源代码,代码片段也可能在生成向量时离开本地环境。对于闭源仓库、金融系统、合规要求严格的团队,这会成为架构层面的顾虑。

grep / glob 在本地文件系统上执行,隐私模型更直接:不上传就不会在远端形成索引状态。安全不再只依赖加密、权限和承诺,而是从数据流上减少外传。

没有索引维护成本

索引系统常见的运维问题包括:

  • 后台索引进程占用 CPU;
  • 分支切换后索引不一致;
  • 缓存损坏需要重建;
  • 新语言或新框架支持不足;
  • 远程索引服务延迟或失败。

实时搜索把这部分成本降到很低。每次搜索都面对当前文件,不需要保证“过去构建的东西仍然正确”。

grep 方案并不总是赢

实时搜索的短板同样明显。它依赖关键词,不擅长“我不知道代码叫什么,但知道大概意思”的场景。

比如想找“防止重复提交订单的逻辑”,代码里可能叫:

  • idempotencyKey
  • deduplicateRequest
  • ensureSingleSubmission
  • requestFingerprint
  • onceOnly

如果没有猜到关键词,grep 很容易漏掉。向量搜索在这种模糊语义场景下更有优势。

传统 IDE 索引也有 grep 无法替代的能力,例如:

  • 精确查找某个函数的所有引用;
  • 安全重命名变量、类、方法;
  • 跨文件类型推断;
  • 根据语言语义区分同名符号;
  • 大规模重构前做静态分析。

所以问题不是“grep 和索引谁更先进”,而是任务需要哪种能力。

场景更适合的路线原因
查找明确函数名、错误码、配置项grep / glob 实时搜索精确、快速、无需准备
分析日志、排查报错堆栈grep / glob 实时搜索报错文本通常就是关键词
初次探索陌生仓库的大概模块向量索引可以用概念搜索,不必猜准确名称
查找语义相近但命名不同的实现向量索引embedding 能捕捉部分语义关联
精确重构、跳转定义、查引用传统 IDE 索引需要语言级语义和类型信息
高合规闭源代码环境本地实时搜索或本地索引关键是代码不要离开受控环境
高频访问的超大单体仓库索引 + 实时校验索引降低查询成本,实时搜索用于确认事实

token 消耗问题要分情况看

有人担心 grep-only 会消耗大量 token。这个担心成立,但只在“把大量搜索结果直接塞给模型”的情况下成立。

成熟的实时搜索流程会控制返回内容:

# 不要返回整个文件,只返回匹配行
rg -n 'processPayment' src

# 需要理解上下文时,只返回前后 3 行
rg -n -C 3 'processPayment' src

# 先限制文件类型和目录
rg -n --glob 'src/**/*.{ts,tsx}' --glob '!**/*.test.ts' 'processPayment'

可以把 agentic search 看成一个逐步收窄的漏斗:

flowchart TB
    A[整个代码库] --> B[按目录和文件名过滤]
    B --> C[按关键词或正则过滤]
    C --> D[返回匹配行]
    D --> E[读取少量相关文件片段]
    E --> F[模型推理和回答]

向量索引也不是免费午餐。它节省的是查询时的扫描成本,但会引入构建成本、存储成本、更新成本和误召回成本。对于小型仓库、短任务、隐私敏感项目,实时搜索的总成本可能更低。

工程上可以怎样设计代码搜索能力

如果要给 AI 编程助手设计搜索工具,可以采用“默认无状态,必要时引入受控状态”的策略。

工具接口保持小而明确

工具不需要暴露复杂状态,只需要让模型明确指定搜索范围、模式和返回数量。

{
  "tool": "grep",
  "pattern": "authenticate|verifyUser|authorization",
  "path": "src",
  "glob": "**/*.{ts,tsx}",
  "context_lines": 3,
  "max_results": 50
}

搜索结果返回证据,而不是结论

工具层不要替模型解释代码,只返回文件路径、行号、匹配内容和少量上下文。

src/middleware/auth.ts:18: export async function authenticate(req, res, next) {
src/routes/order.ts:7: router.post("/orders", authenticate, createOrder)

这样模型可以基于可追溯证据继续推理。

允许会话级缓存,但不要依赖缓存保证正确性

一次会话里,模型可能多次读取同一个文件。短期缓存可以减少重复 I/O,但缓存只能是性能优化,不能成为正确性的前提。

更安全的原则是:

正确性来自当前文件系统
缓存只用于加速
缓存失效时最多变慢,不能返回错误事实

对大型仓库采用混合架构

大型 monorepo 里,完全实时搜索可能成本太高。此时可以引入索引,但要让索引和实时搜索互相校验。

flowchart LR
    Q[用户问题] --> A[AI 助手]
    A --> I[(可选索引)]
    I --> C[候选文件和符号]
    C --> G[grep / 读取当前文件确认]
    G --> A
    A --> R[回答或修改代码]

索引用来缩小候选范围,当前文件内容用来确认事实。这样既利用了索引的速度和语义能力,又避免完全相信可能过期的派生状态。

选择无状态还是有状态的判断标准

一个实用问题是:

如果进程崩溃并重启,用户能否接受从当前输入重新计算?

如果答案是能,系统可以尽量无状态。

如果答案是不能,状态就必须被可靠保存。

系统崩溃后能否从零开始设计倾向
grep 搜索可以重新搜无状态
编译任务多数情况下可以重新编译偏无状态
用户购物车丢失会影响体验有状态
银行交易不能丢失或重复强有状态
游戏存档丢失不可接受有状态
AI 代码索引可以重建,但重建有成本派生状态

状态不是敌人,失控的状态才是问题。真正关键的是把状态放在合适的位置:业务事实放进数据库,临时加速数据放进缓存,搜索确认尽量回到当前文件系统。

Claude Code 的选择说明了什么

Claude Code 使用 grep / glob 这类实时搜索工具,并不意味着代码索引没有价值。它说明的是,在 AI 编程助手这个场景里,简单、确定、本地、可组合这些特性有很高权重。

向量索引擅长语义探索,传统 IDE 索引擅长语言级分析,实时搜索擅长获取当前事实。把三者放在同一个坐标系里看,就不会把技术路线误判成新旧之争。

Claude Code 更像一个 Unix 风格工具:不试图记住整个世界,而是在需要时调用小工具,从当前环境里取证据。对于终端里的代码任务,这种“少保存状态,多依赖事实”的设计,往往比看起来更可靠。


评论