AI 编程助手做代码理解时,常见做法是先建立索引:把代码切块、解析符号、生成向量,查询时再从索引里召回相关片段。Cursor、JetBrains 系 IDE(Integrated Development Environment,集成开发环境)都大量依赖这类机制。
Claude Code 的路线看起来不一样。公开资料中,Anthropic 团队把它称为 agentic search,也就是让模型像开发者在终端里排查问题一样,主动调用 glob、grep 等工具,一轮轮缩小搜索范围,而不是强依赖一个长期维护的代码索引。
这不是简单的“老工具打败新技术”。更准确地说,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(嵌入向量),存入向量数据库。用户搜索“用户认证逻辑”时,即使代码里没有这个完整短语,也可能召回 login、authenticate、verifyUser 等相关实现。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: 解释鉴权调用链和关键位置
这个过程有几个关键点:
glob先缩小文件范围,例如只看src/**/*.ts或app/**/middleware.*。grep再按关键词或正则表达式搜索内容。- 工具返回匹配行和上下文,而不是把整个仓库塞进上下文窗口。
- 模型根据结果继续搜索,直到找到足够证据。
如果手工模拟,可以使用 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 方案并不总是赢
实时搜索的短板同样明显。它依赖关键词,不擅长“我不知道代码叫什么,但知道大概意思”的场景。
比如想找“防止重复提交订单的逻辑”,代码里可能叫:
idempotencyKeydeduplicateRequestensureSingleSubmissionrequestFingerprintonceOnly
如果没有猜到关键词,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 风格工具:不试图记住整个世界,而是在需要时调用小工具,从当前环境里取证据。对于终端里的代码任务,这种“少保存状态,多依赖事实”的设计,往往比看起来更可靠。