Agent CLI深水区 - 搜索系统与代码上下文

Agent CLI 深水区 08:搜索系统与代码上下文

为什么搜索工具比读文件更重要

真实项目里,Agent 不知道该读哪个文件。它需要先定位。

搜索系统通常包括:

  • 文件名搜索:glob(用通配符匹配文件名,Linux shell 里的 *.txtsrc/**/*.ts 就是 glob 模式)。
  • 正文搜索:grep(按文本或正则搜文件内容,就是你在 Linux 里用的那个 grep)。
  • 结构搜索:AST / symbol(AST 即抽象语法树,代码被解析后的树状结构;symbol 指类、方法、接口这类有名字的定义。结构搜索按代码结构查,不是按纯文本查)。
  • Git 状态:哪些文件被改动过。
  • 最近文件:本次会话里刚读过、改过的文件。
  • 语义搜索:embedding / RAG(embedding 是把一段文字压成一个数字向量,意思相近的文字向量距离也近,靠算距离就能搜“意思接近”的内容;RAG 全称 Retrieval-Augmented Generation,套路是先检索出相关资料,再连同问题一起喂给模型回答)。

没有搜索,Agent 只能瞎猜路径。

搜索工具分层

搜索工具分层 用户任务 路径已知? read_file glob/grep rank/filter code context 知道 不知道

GlobTool

代码块TS · 31 行收起展开
// GlobInput = 文件名/路径模式搜索的输入。
// 适合“我大概知道文件名长什么样”,例如 **/*config*.ts。
type GlobInput = {
  pattern: string; // glob 表达式,例如 "src/**/*.ts"。
  limit?: number;  // 最多返回多少条,避免结果撑爆上下文。
};

// globTool = 文件名搜索工具。
// 职责:在 workspace 内找文件,并过滤掉 node_modules、dist 等无意义目录。
export async function globTool(input: GlobInput, ctx: ToolContext): Promise<ToolResult> {
  // limit 由模型传入,但程序要设置上限,防止模型请求 100000 条。
  const limit = Math.min(input.limit ?? 100, 500);

  // cwd 限制在当前项目;ignore 避免搜依赖、Git 内部目录、构建产物。
  const matches = await glob(input.pattern, {
    cwd: ctx.cwd,
    ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"]
  });

  // 只返回前 limit 条,但 metadata 里保留总数。
  const limited = matches.slice(0, limit);

  return {
    summary: `Found ${matches.length} files, returning ${limited.length}.`,
    content: limited.join("\n"),
    metadata: {
      total: matches.length,
      returned: limited.length
    }
  };
}

关键点:

  • 忽略 node_modules.git、构建产物。
  • 限制返回数量。
  • 返回 total 和 returned。

GrepTool

代码块TS · 29 行收起展开
// GrepInput = 正文搜索的输入。
// 适合“我知道某个函数名、错误信息、配置 key 或可见文案”。
type GrepInput = {
  pattern: string; // 要搜索的文本或正则。
  include?: string; // 限定文件范围,例如 "src/**/*.ts"。
  limit?: number;   // 最多返回多少条。
};

// grepTool = 正文搜索工具。
// 真实实现通常调用 ripgrep,因为它快、支持 ignore、输出可控。
export async function grepTool(input: GrepInput, ctx: ToolContext): Promise<ToolResult> {
  // runRipgrep 是对 rg 的封装,负责执行命令、解析输出、截断结果。
  const result = await runRipgrep({
    pattern: input.pattern,
    include: input.include,
    cwd: ctx.cwd,
    limit: Math.min(input.limit ?? 100, 500)
  });

  return {
    summary: `Found ${result.total} matches, returning ${result.items.length}.`,
    // formatGrepMatches 应输出 path:line:text,方便模型继续 read_file。
    content: formatGrepMatches(result.items),
    metadata: {
      total: result.total,           // 实际命中总数。
      returned: result.items.length  // 返回给模型的条数。
    }
  };
}

代码注释里提到的 ripgrep(命令行叫 rg)是用 Rust 写的 grep 替代品,速度快很多,默认还会跳过 .gitignore 里的文件,所以 Agent CLI 基本都用它。

格式建议:

代码块PLAINTEXT · 2 行收起展开
src/queryEngine.ts:42: const response = await callModel(...)
src/tool.ts:80: export async function executeToolCall(...)

这种格式模型容易继续调用 read_file

搜索结果排序

搜索结果不应该原样全塞给模型。要排序。

评分因素:

因素说明
文件名匹配queryEngine.ts 比随机文件重要
路径层级src/ 通常比 dist/ 重要
最近修改当前任务相关概率更高
Git changed已改文件优先
match count命中多可能相关
文件大小太大文件降低优先级
代码块TS · 25 行收起展开
// scoreSearchResult = 给搜索结果打分。
// 分数越高,越应该优先读这个文件。
function scoreSearchResult(item: SearchResult, query: string) {
  let score = 0;

  // 文件名直接包含 query,通常比正文偶然命中更可靠。
  if (item.path.toLowerCase().includes(query.toLowerCase())) score += 5;

  // src/ 下的源码通常比 dist/、docs/ 更接近可修改对象。
  if (item.path.startsWith("src/")) score += 3;

  // test 文件对理解预期行为很重要,所以也加分。
  if (item.path.includes("test")) score += 1;

  // 当前 Git 已改文件要优先,因为用户可能正在围绕它工作。
  if (item.gitChanged) score += 4;

  // 命中越多越可能相关,但最多加 10,避免大文件靠重复刷分。
  score += Math.min(item.matchCount, 10);

  // 太大的文件通常不适合直接塞给模型,先降权。
  if (item.sizeBytes > 200_000) score -= 5;

  return score;
}

CodeContext

读文件后,不一定要把整文件塞给模型。可以构造代码上下文:

代码块TS · 13 行收起展开
// CodeContext = 读完文件后整理出的结构化上下文。
// 它比“整文件文本”更适合喂给模型,因为它保留了路径、语言、符号和切片。
type CodeContext = {
  path: string;      // 文件路径。
  language: string;  // 编程语言,例如 ts/java/python,用来决定解析策略。
  imports: string[]; // import/require/use 列表,帮助模型理解依赖。
  symbols: string[]; // 文件里定义的类、函数、接口等符号名。
  relevantSlices: Array<{
    startLine: number; // 切片开始行。
    endLine: number;   // 切片结束行。
    text: string;      // 这段源码。
  }>;
};

这样模型拿到的就有路径、语言、依赖、符号和具体代码片段,不用自己从一大坨纯文本里猜结构。

行号读取

Agent 经常只需要某个范围。整文件塞进去很浪费 token(token 是模型处理文本的计量单位,大概一个英文单词或半个汉字算一个,上下文窗口的大小和 API 计费都按 token 算)。

代码块TS · 39 行收起展开
// ReadFileInput = 读取文件工具的输入。
// startLine/endLine 让模型只读关键范围,节省 token。
type ReadFileInput = {
  path: string;       // 要读取的文件路径,必须在 workspace 内。
  startLine?: number; // 可选:从第几行开始,1-based。
  endLine?: number;   // 可选:读到第几行,1-based。
};

// readFileTool = 带行号的文件读取工具。
// 带行号的输出能帮助后续 edit/diff/code review 精确定位。
export async function readFileTool(input: ReadFileInput, ctx: ToolContext) {
  // 路径解析和越界检查。
  const abs = resolveWorkspacePath(ctx.cwd, input.path);

  // 教学版默认 UTF-8。真实系统还要处理大文件、二进制、编码。
  const text = await readFile(abs, "utf8");
  const lines = text.split(/\r?\n/);

  // 没传行号就读全文件;真实系统要对全文件读取设置大小上限。
  const start = input.startLine ?? 1;
  const end = input.endLine ?? lines.length;

  // slice 用 0-based 下标,所以 start - 1。
  // 输出时重新加回真实行号。
  const selected = lines
    .slice(start - 1, end)
    .map((line, index) => `${start + index}: ${line}`)
    .join("\n");

  return {
    summary: `Read ${input.path}:${start}-${end}`,
    content: selected,
    metadata: {
      totalLines: lines.length, // 文件总行数,方便模型判断是否还要继续读。
      startLine: start,         // 本次读取开始行。
      endLine: end              // 本次读取结束行。
    }
  };
}

带行号的好处:

  • 后续 edit 更精确。
  • 用户能定位。
  • 代码审查更方便。

Git Context

Agent 做代码任务时,必须知道工作区状态。

代码块TS · 8 行收起展开
// GitContext = 当前工作区的 Git 状态摘要。
// 它的意义是提醒模型:哪些文件是用户已经改过的,不能随便覆盖。
type GitContext = {
  branch: string;          // 当前分支。
  changedFiles: string[];  // 已修改但未必 staged 的文件。
  stagedFiles: string[];   // 已暂存文件。
  untrackedFiles: string[];// Git 还没跟踪的新文件。
};

注入到上下文:

代码块PLAINTEXT · 4 行收起展开
Git status:
- branch: feature/agent-cli
- modified: src/queryEngine.ts
- untracked: docs/notes.md

这样模型会优先关注当前改动,不会无视用户已有修改。

搜索工具描述怎么写

给模型的工具描述要带策略:

代码块PLAINTEXT · 4 行收起展开
Use grep when you know a symbol, error message, function name, or config key.
Use glob when you know a filename pattern.
Prefer grep/glob before reading many files.
Do not read large generated files unless necessary.

模型调用工具的质量,很依赖工具描述。

搜索失败如何反馈

坏反馈:

代码块PLAINTEXT · 1 行收起展开
No matches.

好反馈:

代码块PLAINTEXT · 5 行收起展开
No matches for "QueryEngine" in src/**/*.ts.
Suggestions:
- Try case-insensitive search.
- Search for "query" or "runAgent".
- Use glob to inspect candidate files.

这样模型有下一步。

搜索系统常见坑

后果修复
搜 node_modules输出爆炸ignore 默认目录
不限制结果撑爆上下文limit + total
无排序重要文件淹没score
只支持全文读token 浪费行号读取
无 git context覆盖用户改动注入 status
grep 输出无行号后续编辑困难path:line:text

修复列都是前面各节讲过的机制:限制返回条数并附带 total、给结果打分排序、把 git status 注入上下文、grep 输出统一成 path:line:text 格式,忘了的往上翻对应小节。

读源码时看搜索系统

重点找:

  1. glob/grep 是否分开。
  2. 是否默认忽略构建目录。
  3. 输出是否有限制。
  4. 是否带行号。
  5. 是否有排序。
  6. 是否注入 git status。
  7. 是否支持范围读取。
  8. 搜索失败是否给模型可恢复信息。

搜索系统决定 Agent 是“会找上下文”,还是“靠猜”。

搜索不是 grep,而是 Context Retrieval

在 Agent CLI 里,搜索系统的最终职责是把一个模糊任务变成足够可靠的代码上下文,“把匹配行列出来”只是中间一步。

用户请求通常长这样:

  • “这个接口 500 了,帮我修。”
  • “把登录改成手机号验证码。”
  • “review 一下这次改动。”
  • “为什么这个测试过不了?”

这些请求里未必包含文件名、类名、函数名。搜索系统要做的是:

ContextRetriever 闭环 User Task Classify Candidates Rank Read Slice Package Reason Plan/Edit need more

所以可以把它叫作 ContextRetriever:

代码块TS · 6 行收起展开
// ContextRetriever = 上下文检索器接口。
// 输入是用户任务和工作区信息,输出是“模型可以行动的证据包”。
type ContextRetriever = {
  // retrieve 不追求返回最多内容,而是返回最相关、最省 token 的内容。
  retrieve(task: UserTask, ctx: WorkspaceContext): Promise<ContextPackage>;
};

它的目标是在 token 预算内返回“最可能让模型做对事”的证据。返回得多本身没有价值,反而挤占预算。

任务类型决定搜索策略

不同任务的搜索入口不同。下面这张表按任务类型给出先搜什么、再搜什么:比如用户说“接口 500 了”,第一步应该拿错误信息或 stack trace 去 grep,而不是漫无目的地读文件。

任务类型第一优先搜索第二优先搜索需要补充
报错修复error message / stack tracetest name / route失败日志、相关测试
功能修改feature keywordcontroller/service/model相邻实现、测试样例
代码 reviewgit diffchanged files symbols调用方、测试覆盖
重构symbol searchreferences类型定义、边界接口
配置问题config key / env vardocs / scripts启动命令、默认值
UI 问题visible text / component nameroute / page filescreenshot、CSS
Agent 行为问题trace span / tool namestate machineprompts、tool schema

最后一行「Agent 行为问题」单独说一下,这是指你在调试 Agent 本身,比如它乱调工具、卡死在循环里。
trace 是 Agent 每一步操作的运行记录,类似后端的请求日志;span 是 trace 里的一小段,对应一次具体操作,比如一次工具调用。
所以第一步是拿工具名去翻运行记录,看它到底调了什么工具、传了什么参数。第二步看 state machine(状态机),也就是 Agent 里控制流程的那部分代码,管着「现在该思考、该调工具还是该结束」,行为跑偏往往是这里的状态转移写错了。
还查不出来就看 prompt 和 tool schema:tool schema 是工具的参数定义,声明这个工具叫什么、收哪些参数、各是什么类型,schema 写得含糊,模型就容易选错工具或传错参数。

一个简单分类器:

代码块TS · 21 行收起展开
// classifyTask = 教学版任务分类器。
// 它决定后续搜索策略:bugfix 优先看错误栈,review 优先看 git diff。
function classifyTask(input: string): TaskKind {
  // 统一小写,方便匹配英文关键词。
  const lower = input.toLowerCase();

  // 报错/失败类任务。
  if (lower.includes("error") || lower.includes("exception") || lower.includes("失败")) return "bugfix";

  // 代码审查类任务。
  if (lower.includes("review") || lower.includes("代码审查")) return "review";

  // 重构类任务。
  if (lower.includes("重构") || lower.includes("rename")) return "refactor";

  // 配置/环境变量类任务。
  if (lower.includes("配置") || lower.includes("env")) return "config";

  // 默认按功能修改处理。
  return "feature";
}

真实系统可以让模型分类,但第一版用规则足够。

候选生成:召回要宽,读取要窄

搜索分两步(借用搜索引擎和推荐系统的行话:第一步叫“召回”,第二步之前还有个“精排”,下一节讲):

  1. 候选生成(召回):把可能相关的文件都捞出来,尽量不要漏。
  2. 上下文读取:从排好序的候选里挑最值得读的,严格控制 token。

有点像 MySQL 用二级索引查询:先在索引里定位出一批主键(很快,范围可以宽),再回表取整行数据(真正贵的操作,次数必须控制住)。

候选生成可以很宽:

代码块TS · 34 行收起展开
// Candidate = 一个“可能相关文件”的候选项。
// 注意它还不是最终上下文,只是待排序、待读取的候选。
type Candidate = {
  path: string;                         // 文件路径。
  reason: string;                       // 为什么被召回,方便 trace 和调试。
  scoreSignals: Record<string, number>; // 打分信号,例如 gitChanged=1、stackTrace=1。
};

// generateCandidates = 候选召回阶段。
// 原则:召回可以宽一点,后面排序和预算读取会负责收窄。
async function generateCandidates(task: UserTask, ctx: WorkspaceContext): Promise<Candidate[]> {
  const candidates: Candidate[] = [];

  // 用户当前改动过的文件通常很相关。
  candidates.push(...await fromGitChangedFiles(ctx));

  // 如果用户贴了错误栈,从栈里的文件路径召回。
  candidates.push(...await fromErrorStack(task));

  // 从用户输入里抽关键词做 grep。
  candidates.push(...await fromGrepKeywords(task));

  // 从用户输入里抽可能的文件名/模块名做 glob。
  candidates.push(...await fromFileName(task));

  // 最近会话读过/改过的文件,也可能继续相关。
  candidates.push(...await fromRecentSessionFiles(ctx));

  // 测试名、失败用例名常常能直接定位预期行为。
  candidates.push(...await fromTestNames(task));

  // 同一个文件可能被多种方式召回,要合并信号而不是重复返回。
  return dedupeCandidates(candidates);
}

但读取阶段必须窄:

代码块TS · 7 行收起展开
// ReadBudget = 上下文读取预算。
// 候选可以很多,但真正读进模型的内容必须受控。
type ReadBudget = {
  maxFiles: number;       // 最多读几个文件。
  maxTotalTokens: number; // 这些文件切片总共最多多少 token。
  maxLinesPerFile: number;// 单个文件最多读多少行。
};

不要把候选文件全部读进上下文。候选只是地图,不是上下文。

精排:让重要文件浮上来

精排就是上一节预告的那一步:候选都召回了,现在按各种信号加权打分,决定先读谁。一个实用评分模型:

代码块TS · 33 行收起展开
// rankCandidate = 对候选文件精排。
// 它把多个信号加权,得到“应该先读哪个文件”的分数。
function rankCandidate(c: Candidate, task: UserTask): number {
  let score = 0;

  // 错误栈直接命中的文件,优先级最高。
  score += 10 * (c.scoreSignals.stackTrace ?? 0);

  // 当前 Git 改动文件也很关键,避免无视用户已有修改。
  score += 8 * (c.scoreSignals.gitChanged ?? 0);

  // 文件名匹配通常比正文偶然命中更强。
  score += 6 * (c.scoreSignals.fileNameMatch ?? 0);

  // 符号名匹配,例如函数/类/接口名命中。
  score += 5 * (c.scoreSignals.symbolMatch ?? 0);

  // 测试文件说明预期行为,修 bug 时很有价值。
  score += 3 * (c.scoreSignals.testFile ?? 0);

  // 最近读过的文件有连续任务相关性。
  score += 2 * (c.scoreSignals.recentFile ?? 0);

  // 生成文件、依赖目录、构建产物通常不该优先读。
  if (isGenerated(c.path)) score -= 20;
  if (isVendored(c.path)) score -= 20;

  // 巨大文件降低优先级,除非有更强信号。
  if (isHugeFile(c.path)) score -= 5;
  if (pathLooksLikeBuildOutput(c.path)) score -= 15;

  return score;
}

注意:测试文件不是低价值文件。
很多修 bug 任务里,测试文件比实现文件更能说明预期行为。

搜索结果应该可解释

不要只给模型:

代码块JSON · 1 行收起展开
["src/a.ts", "src/b.ts"]

更好的格式:

代码块PLAINTEXT · 8 行收起展开
Candidate files:
1. src/auth/loginService.ts
   score: 22
   reasons: symbol match "login", route keyword "auth", referenced by failing test

2. test/auth/loginService.test.ts
   score: 18
   reasons: failing test name, assertion mentions "phone code"

可解释性有两个好处:

  • 模型知道为什么读这个文件。
  • Trace(Agent 每一步操作的运行记录,类似后端的请求日志)里能复盘为什么漏读或误读。

Symbol Index:从全文搜索升级到结构搜索

全文 grep 找字符串,Symbol Index 找结构。类比 MySQL:grep 相当于全表扫描加 LIKE ‘%xxx%‘,Symbol Index 相当于提前建好的索引,直接按“类名”“方法名”这种结构化字段查。

代码块TS · 11 行收起展开
// SymbolRecord = 代码里的一个“符号”。
// 符号可以是类、函数、方法、接口、常量等,是结构搜索的基础。
type SymbolRecord = {
  name: string; // 符号名,例如 LoginService、handleSubmit。
  kind: "class" | "function" | "method" | "interface" | "type" | "constant"; // 符号类型。
  path: string;       // 所在文件。
  startLine: number;  // 定义开始行。
  endLine: number;    // 定义结束行。
  signature?: string; // 函数签名/方法签名,帮助模型快速理解入参返回。
  exports?: boolean;  // 是否对外导出。导出符号影响面更大。
};

来源可以逐步升级:

阶段实现方式适合
入门regex 抽 function/class小项目
中级tree-sitter多语言结构解析
高级LSP / tsserver / javac引用、定义、类型
工程级索引服务 + 增量更新大仓库

表里几个名词:tree-sitter 是一个支持几十种语言的语法解析库,能把源码解析成语法树;LSP(Language Server Protocol)是编辑器和“语言服务”之间的标准协议,IDEA 里的跳转定义、查找引用背后就是这类服务在干活;tsserver 是 TypeScript 官方的语言服务。

对于 Java 后端项目,最好能索引:

  • Controller 路由方法。
  • Service 方法。
  • Repository / Mapper。
  • DTO / Entity。
  • 配置类和配置项。
  • 测试类和测试方法。

这样用户说“登录接口”,Agent 能从路由跳到 Service,再跳到测试。

Call Graph:知道谁调用谁

只读当前函数常常不够。Agent 还要知道调用关系。

代码块TS · 7 行收起展开
// CallGraphEdge = 调用图的一条边。
// 表示 from 这个符号通过某种关系依赖 to 这个符号。
type CallGraphEdge = {
  from: SymbolRef; // 调用方/依赖方。
  to: SymbolRef;   // 被调用方/被依赖方。
  kind: "call" | "new" | "implements" | "extends" | "route"; // 关系类型。
};

最常用的两个查询:

代码块TS · 2 行收起展开
findReferences(symbol): SymbolRef[]
findCallees(symbol): SymbolRef[]

调 bug 时常用:

入口 Controller → Service 方法 → Repository/Client → 异常发生点

做重构时常用:

要改的函数 → 所有调用方 → 所有测试 → 对外导出位置

没有 call graph,Agent 很容易只改定义,不改调用方。

Code Slicing:不要整文件阅读

大文件最浪费 token。更好的方式是切片。

代码块TS · 10 行收起展开
// CodeSlice = 真正喂给模型的一段代码证据。
// 它不是整文件,而是“路径 + 行号 + 片段 + 为什么读它”。
type CodeSlice = {
  path: string;       // 文件路径。
  startLine: number;  // 片段开始行。
  endLine: number;    // 片段结束行。
  text: string;       // 片段文本。
  reason: string;     // 为什么这个片段相关,例如 around matched line 42。
  symbols: string[];  // 片段里涉及的符号。
};

切片策略:

情况切片方式
grep 命中某行命中行前后 30 行
命中函数名整个函数
命中类名类签名 + 相关方法
测试失败失败测试方法 + beforeEach/helper
配置项配置定义 + 使用点
路由路由方法 + service 调用

示例:

代码块TS · 19 行收起展开
// sliceAroundLine = 围绕某个命中行读取上下文。
// 常用于 grep 命中后,给模型看命中行前后若干行。
function sliceAroundLine(file: TextFile, line: number, radius = 30): CodeSlice {
  // start/end 是 1-based 行号,注意不要越界。
  const start = Math.max(1, line - radius);
  const end = Math.min(file.lines.length, line + radius);

  return {
    path: file.path,       // 来源文件。
    startLine: start,      // 片段开始行。
    endLine: end,          // 片段结束行。
    // 数组 slice 用 0-based,所以 start 要减 1。
    text: file.lines.slice(start - 1, end).join("\n"),
    // reason 会进入 trace,方便复盘为什么读这段。
    reason: `around matched line ${line}`,
    // 找出这段范围内有哪些函数/类,给模型更强结构感。
    symbols: findSymbolsInRange(file.symbols, start, end)
  };
}

Context Package 格式

最终给模型的上下文不要是散乱文本,应该是结构化包。

代码块TS · 25 行收起展开
// ContextPackage = 一次检索最终交给模型的上下文包。
// 它把任务、工作区、证据、缺口都结构化,避免散乱粘贴。
type ContextPackage = {
  task: string; // 用户任务原文。
  workspace: {
    cwd: string;            // 当前项目根目录。
    branch?: string;        // 当前 Git 分支。
    changedFiles: string[]; // 已改文件,提醒模型不要覆盖用户改动。
  };
  evidence: EvidenceBlock[]; // 已读取的证据块。
  openQuestions: string[];   // 仍缺失的信息,例如“还没找到测试命令”。
  omitted: {
    reason: string; // 为什么没放进上下文,例如 token budget。
    count: number;  // 被省略的数量。
  }[];
};

// EvidenceBlock = 给模型看的一个证据块。
type EvidenceBlock = {
  id: string;                // 证据 ID,例如 E1,方便模型引用。
  path: string;              // 来源文件。
  lines?: [number, number];  // 行号范围。
  reason: string;            // 为什么这段证据相关。
  content: string;           // 证据正文。
};

模型提示里可以这样写:

代码块PLAINTEXT · 3 行收起展开
Use the evidence blocks below. When proposing an edit, cite the evidence id
that justifies the change. If evidence is insufficient, ask for or retrieve
more context before editing.

这会显著降低“没看够就改”的概率。

Hybrid Search:关键词 + 语义

Embedding 搜索适合“意思相近但词不一样”的场景,但不适合完全替代 grep。

搜索方式强项弱项
grep精确符号、错误信息、配置 key不理解语义
glob文件名定位不看内容
symbol定义/引用/类型建索引成本高
embedding语义相近、文档说明对变量名和精确错误弱
git context当前修改相关只覆盖已有变更

成熟做法是 hybrid(混合搜索:几种搜索器一起跑,再把结果融合):

代码块TS · 8 行收起展开
// Hybrid Search 教学版:
// lexical 负责精确词,semantic 负责语义相近,symbols 负责代码结构。
const lexical = await grepSearch(query);
const semantic = await vectorSearch(query);
const symbols = await symbolSearch(query);

// RRF 把多个搜索器的排名融合,避免单一搜索器误判。
const merged = reciprocalRankFusion([lexical, semantic, symbols]);

RRF 全称 Reciprocal Rank Fusion(倒数排名融合)。直觉是:多个搜索器都觉得相关的结果,融合后排名更高。

代码块TS · 5 行收起展开
// rrf = Reciprocal Rank Fusion 的单项得分。
// rank 越靠前,得分越高;k 用来平滑,避免第一名压倒一切。
function rrf(rank: number, k = 60) {
  return 1 / (k + rank);
}

搜索失败不是终点

搜索失败要变成策略调整。

代码块TS · 12 行收起展开
// SearchFailure = 搜索失败时返回给模型的结构化信息。
// 目的:让模型知道已经试过什么,下一步该换什么关键词/范围。
type SearchFailure = {
  query: string; // 原始搜索词。
  scope: string; // 搜索范围,例如 src/**/*.ts。
  attempts: Array<{
    tool: "grep" | "glob" | "symbol" | "semantic"; // 用了哪个搜索器。
    query: string;       // 这次实际搜索词。
    resultCount: number; // 返回数量。
  }>;
  suggestions: string[]; // 建议下一步,例如换同义词、扩大范围、先看入口文件。
};

例子:

代码块PLAINTEXT · 9 行收起展开
No matches for "QueryEngine" in src/**/*.ts.
Tried:
- grep "QueryEngine": 0
- glob "**/*Query*": 0

Suggestions:
- search "engine"
- search "runAgent"
- inspect package entrypoints

Agent 下一轮就不会原地打转。

上下文预算分配

搜索系统要配合上下文预算。

一个实用分配:

内容预算比例
系统规则 / 工具说明15%
用户任务与历史10%
Git 状态5%
搜索证据45%
相关测试/日志15%
预留给模型输出10%

如果证据太多,优先级:

错误栈命中文件 > 当前 git 改动文件 > 测试文件 > 入口文件 > 被调用实现 > 文档 > 历史相似文件

评估搜索质量

搜索系统也需要 eval(评测:准备一组固定用例,跑一遍检索,用指标打分。之后每次改代码都重跑,防止改好一处、退步另一处)。

指标含义
recall@k正确文件是否出现在前 k 个候选
MRR正确文件排名是否靠前
token cost为找到上下文花了多少 token
edit success rate检索后修改是否一次成功
re-read rate是否频繁因为上下文不足重读
false context rate读了很多无关文件

前两个指标来自搜索引擎领域。recall@k 读作“前 k 召回率”:比如 recall@5 就是“正确文件出现在前 5 个候选里”的用例占比。
MRR 全称 Mean Reciprocal Rank(平均倒数排名):正确文件排第 1 记 1 分,排第 2 记 1/2,排第 5 记 1/5,所有用例取平均,越接近 1 说明正确文件普遍排得越靠前。

可以造一个小型 benchmark(基准测试集,说白了就是一堆固定用例加期望答案):

代码块TS · 7 行收起展开
// RetrievalCase = 检索评估用例。
// 用来测试“给定任务时,retriever 能不能把正确文件排到前面”。
type RetrievalCase = {
  task: string;              // 用户任务描述。
  expectedFiles: string[];   // 正确答案应该包含的文件。
  expectedSymbols?: string[];// 可选:正确答案应该包含的函数/类。
};

测试:

代码块TS · 13 行收起展开
// 这个测试验证:用户说“手机号验证码登录”时,检索器能找到实现和测试。
it("retrieves auth service for phone login task", async () => {
  // 执行检索。
  const result = await retriever.retrieve({
    text: "把登录改成手机号验证码"
  }, workspace);

  // 前 5 个候选里应该有业务实现。
  expect(topPaths(result, 5)).toContain("src/auth/loginService.ts");

  // 前 5 个候选里也应该有测试,测试说明预期行为。
  expect(topPaths(result, 5)).toContain("test/auth/loginService.test.ts");
});

索引生命周期与新鲜度

搜索系统最容易被低估的问题是“搜到了旧东西”,它比“搜不到”更隐蔽。Agent 如果拿旧代码、旧笔记、旧测试当证据,后面的推理越认真,错得越有说服力。

这个问题可以直接套 Redis 缓存和 MySQL 的关系来理解:索引(尤其 embedding 向量索引,建一次很贵)就是缓存,磁盘上的源文件才是真源。文件改了但索引没更新,等于缓存和数据库不一致,检索出来的就是脏数据。所以这一节讲的 hash 校验、重建策略,本质上就是缓存失效那一套。

生产级检索要把索引当成一条流水线来管理。下面表格按流程顺序从上往下读:“关键字段”列是这个阶段要记录的数据,“失败表现”列是这个阶段做砸了的症状(chunk 指切出来的一小段内容,是索引和检索的基本单元;Retrieve 行里的 topK 指只取得分最靠前的 K 条候选,比如 topK=5 就是只要前 5 名,它和前面评估一节 recall@k 里的 k 是同一个东西):

阶段做什么关键字段失败表现
Discover找到可索引文件path、language、size、ignored把 vendor/build/cache 放进索引
Parse提取文本、符号、行号symbol、range、heading、hash行号错、函数边界错
Chunk切分上下文单元chunkId、parentPath、startLine、endLinechunk 太碎或跨语义边界
Embed / Index写入向量/关键词/符号索引contentHash、indexVersion、embeddedAt索引滞后、版本混用
Retrieve召回候选query、retriever、score、topK正确文件不在候选里
Validate校验新鲜度和可读性fileHashNow、fileHashIndexed、exists文件已删、内容已改
Package生成上下文包selectedReason、tokenCost、sourceRange模型拿不到可引用证据

索引记录要能回答“这段证据是不是当前文件里的这段内容”:

代码块TS · 14 行收起展开
// IndexedChunk = 被检索系统持久化的一段上下文。
// 它必须能在检索后重新校验,不能只保存一段裸文本。
type IndexedChunk = {
  chunkId: string;              // 稳定 chunk ID,用于 trace 和 eval。
  sourcePath: string;           // 源文件路径。
  language: string;             // 文件语言,用于选择 parser/ranker。
  startLine: number;            // chunk 在源文件中的起始行。
  endLine: number;              // chunk 在源文件中的结束行。
  contentHash: string;          // chunk 内容 hash,用于发现旧索引。
  fileHash: string;             // 整个文件 hash,用于快速判断文件是否变过。
  indexVersion: string;         // 索引器版本,parser/chunker 改动后必须更新。
  symbols: string[];            // chunk 内出现的函数、类、接口、标题。
  updatedAt: string;            // 写入索引的时间。
};

检索拿到候选之后,先按下表校验新鲜度,再决定给不给模型。几个词先解释:stale 就是“过期”;dirty file 指改了还没提交的文件;working tree 指磁盘上当前的实际内容,区别于索引里存的旧快照;冷启动指索引还没建好、语义搜索暂时用不了的阶段:

场景策略
文件不存在丢弃候选,并在 trace 记录 stale candidate
文件 hash 变化重新读取当前行范围;如果行范围失效,触发局部重建
indexVersion 过旧降低得分或强制重建
chunk hash 不一致不把旧 chunk 直接给模型
git dirty file优先读 working tree,而不是上次索引内容
大仓库冷启动先用关键词/文件名检索,后台补 embedding

trace 里最关键的内容是“为什么信这个结果”,光记“搜到了什么”不够:

代码块JSONC · 10 行收起展开
{
  "phase": "retrieve_validate",           // 检索后的证据校验阶段。
  "query": "修复登录接口 500",             // 用户任务或改写后的查询。
  "candidateChunkId": "auth-LoginService-42", // 初召回候选。
  "sourcePath": "src/auth/LoginService.java", // 候选来源。
  "indexedHash": "sha256:old",            // 索引中记录的 hash。
  "currentHash": "sha256:new",            // 当前文件实时 hash。
  "decision": "re-read-current-file",     // 因为 hash 变化,所以重新读取当前文件。
  "selected": true                        // 校验后是否进入最终上下文。
}

Agent 读源码时的检索质量,不只看 recall@k,还要看新鲜度:

指标意义
stale candidate rate召回结果里有多少已经过期
revalidation hit rate重新校验后仍然可用的比例
dirty-file priority当前修改文件是否优先进入上下文
source range accuracy引用行号是否仍指向正确代码
index rebuild latency文件修改后多久进入新索引

这套机制的价值是防止 Agent 用“过期证据”生成看似合理的错误答案。

Java 后端项目的检索套路

如果目标是 Spring Boot 项目,可以固定几类检索入口:

用户说法搜索入口
“接口报错”@RequestMapping / @GetMapping / @PostMapping
“某个字段不对”DTO 字段、Entity 字段、Mapper XML
“查库结果不对”Repository / Mapper / SQL XML
“权限问题”Filter / Interceptor / SecurityConfig
“事务问题”@Transactional、Service 调用链
“参数校验”@Valid、Validator、DTO annotation
“配置不生效”application.yml@ConfigurationProperties

搜索系统可以内置语言画像:

代码块TS · 8 行收起展开
// LanguageProfile = 针对不同语言/框架的检索画像。
// Spring Boot、Node、Python 的入口文件和生成目录都不一样,不能一套规则打天下。
type LanguageProfile = {
  routePatterns: RegExp[]; // 路由入口模式,例如 Spring 的 @GetMapping。
  testPatterns: string[];  // 测试文件命名,例如 **/*Test.java。
  generatedDirs: string[]; // 生成目录,例如 target/、dist/。
  configFiles: string[];   // 常见配置文件,例如 application.yml。
};

这样 Java 项目、Node 项目、Python 项目可以用不同策略。

读源码时的高阶问题

读搜索模块时,继续追问:

  1. 搜索器只是工具,还是有 retriever 调度层?
  2. 候选生成和上下文读取是否分离?
  3. 是否记录为什么选择某个文件?
  4. 是否支持符号索引?
  5. 是否知道 git changed files?
  6. 是否能围绕错误栈定位文件?
  7. 是否有上下文预算?
  8. 是否有“搜索失败后的下一步建议”?
  9. 是否评估 recall@k?
  10. 是否避免读取 vendor/generated/build 输出?(vendor 指拷进仓库的第三方依赖目录,类似 node_modules;generated/build 是代码生成器和构建流程的产物)

真正强的 Agent 知道什么时候 grep、grep 什么、读多少、把什么证据交给模型,光会调 grep 差得还远。

延伸阅读