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

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

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

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

搜索系统通常包括:

  • 文件名搜索:glob。
  • 正文搜索:grep。
  • 结构搜索:AST / symbol。
  • Git 状态:改动文件。
  • 最近文件:会话上下文。
  • 语义搜索:embedding / RAG。

没有搜索,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  // 返回给模型的条数。
    }
  };
}

格式建议:

代码块TEXT · 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 经常只需要某个范围。

代码块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 还没跟踪的新文件。
};

注入到上下文:

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

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

搜索工具描述怎么写

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

代码块TEXT · 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.

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

搜索失败如何反馈

坏反馈:

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

好反馈:

代码块TEXT · 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

读源码时看搜索系统

重点找:

  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 预算内返回“最可能让模型做对事”的证据。

任务类型决定搜索策略

不同任务的搜索入口不同。

任务类型第一优先搜索第二优先搜索需要补充
报错修复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

一个简单分类器:

代码块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。

候选生成可以很宽:

代码块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"]

更好的格式:

代码块TEXT · 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 里能复盘为什么漏读或误读。

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

全文 grep 找字符串,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引用、定义、类型
工程级索引服务 + 增量更新大仓库

对于 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 时常用:

代码块TEXT · 4 行收起展开
入口 Controller
-> Service 方法
-> Repository/Client
-> 异常发生点

做重构时常用:

代码块TEXT · 4 行收起展开
要改的函数
-> 所有调用方
-> 所有测试
-> 对外导出位置

没有 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;           // 证据正文。
};

模型提示里可以这样写:

代码块TEXT · 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 的直觉:多个搜索器都觉得相关的结果,排名更高。

代码块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[]; // 建议下一步,例如换同义词、扩大范围、先看入口文件。
};

例子:

代码块TEXT · 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%

如果证据太多,优先级:

代码块TEXT · 7 行收起展开
错误栈命中文件
> 当前 git 改动文件
> 测试文件
> 入口文件
> 被调用实现
> 文档
> 历史相似文件

评估搜索质量

搜索系统也需要 eval。

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

可以造一个小型 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 如果拿旧代码、旧笔记、旧测试当证据,后面的推理越认真,错得越有说服力。

生产级检索要把索引看成有生命周期的工程对象:

阶段做什么关键字段失败表现
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;            // 写入索引的时间。
};

检索前后的新鲜度策略:

场景策略
文件不存在丢弃候选,并在 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 输出?

真正强的 Agent 不是“会 grep”,而是知道什么时候 grep、grep 什么、读多少、把什么证据交给模型。