搜索系统:Glob + Grep + 全文搜索的组合拳

深入 Claude Code 的搜索工具组合——GlobTool 模式匹配、GrepTool ripgrep 集成、ToolSearchTool 延迟发现

问题引入

当 AI 面对一个陌生的代码库时,它的第一个问题不是"怎么改代码",而是"代码在哪里"。在一个包含上万个文件的项目中,找到正确的文件和代码位置是一切操作的前提。

传统的方法是用 findgrep 命令。但这些命令有几个问题:

  1. 权限不可控 — Shell 命令绕过了 Claude Code 的权限系统
  2. 输出不可控grep -r pattern . 可能返回几 MB 的结果,消耗大量 token
  3. 格式不友好 — Shell 命令的输出格式对 AI 来说并不总是最优的

Claude Code 的解决方案是三个专用搜索工具:GlobTool(按文件名模式查找)、GrepTool(按内容搜索)、ToolSearchTool(延迟工具发现)。它们各自解决不同层面的搜索问题,组合使用时形成一个强大的搜索体系。


GlobTool:文件模式匹配

GlobTool 是最简单的搜索工具——给定一个 glob 模式(如 **/*.ts),返回所有匹配的文件路径。

输入与输出

src/tools/GlobTool/GlobTool.ts:26-53
TypeScript
26const inputSchema = lazySchema(() =>
27 z.strictObject({
28 pattern: z.string().describe('The glob pattern to match files against'),
29 path: z
30 .string()
31 .optional()
32 .describe(
33 'The directory to search in. If not specified, the current working directory will be used...',
34 ),
35 }),
36)
37
38const outputSchema = lazySchema(() =>
39 z.object({
40 durationMs: z.number().describe('Time taken to execute the search'),
41 numFiles: z.number().describe('Total number of files found'),
42 filenames: z.array(z.string()).describe('Array of file paths'),
43 truncated: z.boolean().describe('Whether results were truncated (limited to 100 files)'),
44 }),
45)

仅两个输入参数:pattern 和可选的 path。输出包含四个字段,其中 truncated 标志告诉 AI 结果是否被截断了。

100 文件截断

src/tools/GlobTool/GlobTool.ts:154-176
TypeScript
154 async call(input, { abortController, getAppState, globLimits }) {
155 const start = Date.now()
156 const appState = getAppState()
157 const limit = globLimits?.maxResults ?? 100
158 const { files, truncated } = await glob(
159 input.pattern,
160 GlobTool.getPath(input),
161 { limit, offset: 0 },
162 abortController.signal,
163 appState.toolPermissionContext,
164 )
165 // Relativize paths under cwd to save tokens
166 const filenames = files.map(toRelativePath)
167 const output: Output = {
168 filenames,
169 durationMs: Date.now() - start,
170 numFiles: filenames.length,
171 truncated,
172 }
173 return { data: output }
174 },

默认限制 100 个文件。当结果被截断时,返回消息提示 AI 使用更具体的路径或模式。

截断后的消息:

src/tools/GlobTool/GlobTool.ts:186-196
TypeScript
186 mapToolResultToToolResultBlockParam(output, toolUseID) {
187 if (output.filenames.length === 0) {
188 return { tool_use_id: toolUseID, type: 'tool_result', content: 'No files found' }
189 }
190 return {
191 tool_use_id: toolUseID,
192 type: 'tool_result',
193 content: [
194 ...output.filenames,
195 ...(output.truncated
196 ? ['(Results are truncated. Consider using a more specific path or pattern.)']
197 : []),
198 ].join('\n'),
199 }
200 },

路径相对化

TypeScript
1// Relativize paths under cwd to save tokens (same as GrepTool)
2const filenames = files.map(toRelativePath)

所有返回的路径都被相对化——/Users/noah/project/src/index.ts 变成 src/index.ts。这是一个 token 优化:绝对路径中的项目根路径前缀在每个文件中重复出现,相对化后可以节省大量 token。

并发安全性

src/tools/GlobTool/GlobTool.ts:76-81
TypeScript
76 isConcurrencySafe() {
77 return true
78 },
79 isReadOnly() {
80 return true
81 },

GlobTool 是完全并发安全的只读操作。多个 GlobTool 调用可以并行执行而不会互相干扰。这意味着 AI 可以同时搜索 **/*.ts**/*.tsx 而不需要串行化。


GrepTool:基于 ripgrep 的内容搜索

GrepTool 是搜索系统的核心,基于 ripgrep(rg)构建,提供了远超原生 grep 的功能。

丰富的输入 Schema

src/tools/GrepTool/GrepTool.ts:33-89
TypeScript
33const inputSchema = lazySchema(() =>
34 z.strictObject({
35 pattern: z.string().describe('The regular expression pattern to search for'),
36 path: z.string().optional().describe('File or directory to search in'),
37 glob: z.string().optional().describe('Glob pattern to filter files'),
38 output_mode: z.enum(['content', 'files_with_matches', 'count']).optional(),
39 '-B': semanticNumber(z.number().optional()).describe('Lines before match'),
40 '-A': semanticNumber(z.number().optional()).describe('Lines after match'),
41 '-C': semanticNumber(z.number().optional()).describe('Alias for context'),
42 context: semanticNumber(z.number().optional()).describe('Lines before and after'),
43 '-n': semanticBoolean(z.boolean().optional()).describe('Show line numbers'),
44 '-i': semanticBoolean(z.boolean().optional()).describe('Case insensitive'),
45 type: z.string().optional().describe('File type (js, py, rust, etc.)'),
46 head_limit: semanticNumber(z.number().optional()).describe('Limit output'),
47 offset: semanticNumber(z.number().optional()).describe('Skip first N entries'),
48 multiline: semanticBoolean(z.boolean().optional()).describe('Multiline mode'),
49 }),
50)

13 个参数!这是 Claude Code 中参数最多的工具。设计理念是:把 ripgrep 的核心能力直接暴露给 AI,而不是做过多抽象。

三种输出模式

GrepTool 输出模式
GrepTool
files_with_matches
(默认) · 只返回文件名
content
返回匹配行 · 支持上下文
count
返回匹配计数 · filename:count
  • files_with_matches — 默认模式。只返回匹配文件的路径,按修改时间排序。适合先定位再精读
  • content — 返回匹配行及其上下文。支持 -A/-B/-C 控制上下文行数
  • count — 返回每个文件的匹配次数。适合快速评估搜索范围

分页系统

src/tools/GrepTool/GrepTool.ts:106-128
TypeScript
106const DEFAULT_HEAD_LIMIT = 250
107
108function applyHeadLimit<T>(
109 items: T[],
110 limit: number | undefined,
111 offset: number = 0,
112): { items: T[]; appliedLimit: number | undefined } {
113 // Explicit 0 = unlimited escape hatch
114 if (limit === 0) {
115 return { items: items.slice(offset), appliedLimit: undefined }
116 }
117 const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT
118 const sliced = items.slice(offset, offset + effectiveLimit)
119 // Only report appliedLimit when truncation actually occurred
120 const wasTruncated = items.length - offset > effectiveLimit
121 return {
122 items: sliced,
123 appliedLimit: wasTruncated ? effectiveLimit : undefined,
124 }
125}

默认限制 250 条结果。设计亮点:

  1. limit: 0 是"无限制"的逃生舱口
  2. appliedLimit 只在实际发生截断时才设置,告诉 AI 可以使用 offset 分页查看更多结果
  3. offset 参数实现了 tail -n +N | head -N 的效果

排除目录

src/tools/GrepTool/GrepTool.ts:94-102
TypeScript
94const VCS_DIRECTORIES_TO_EXCLUDE = [
95 '.git', '.svn', '.hg', '.bzr', '.jj', '.sl',
96] as const

版本控制目录自动排除,因为搜索 .git 内部几乎不会有用且会产生大量噪音。支持 6 种版本控制系统(Git、SVN、Mercurial、Bazaar、Jujutsu、Sapling)。

ripgrep 参数构建

src/tools/GrepTool/GrepTool.ts:329-441
TypeScript
329 async call({ pattern, path, glob, type, output_mode = 'files_with_matches',
330 '-B': context_before, '-A': context_after, '-C': context_c, context,
331 '-n': show_line_numbers = true, '-i': case_insensitive = false,
332 head_limit, offset = 0, multiline = false,
333 }, { abortController, getAppState }) {
334 const absolutePath = path ? expandPath(path) : getCwd()
335 const args = ['--hidden']
336
337 for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
338 args.push('--glob', `!${dir}`)
339 }
340
341 args.push('--max-columns', '500') // Limit line length
342
343 if (multiline) {
344 args.push('-U', '--multiline-dotall')
345 }
346 // ... build more args
347 }

注意 --max-columns 500:限制行宽为 500 字符,防止 base64 编码或压缩后的内容(通常一行几千字符)淹没搜索结果。

files_with_matches 模式的排序

src/tools/GrepTool/GrepTool.ts:529-553
TypeScript
529 const stats = await Promise.allSettled(
530 results.map(_ => getFsImplementation().stat(_)),
531 )
532 const sortedMatches = results
533 .map((_, i) => {
534 const r = stats[i]!
535 return [
536 _,
537 r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0,
538 ] as const
539 })
540 .sort((a, b) => {
541 if (process.env.NODE_ENV === 'test') {
542 return a[0].localeCompare(b[0]) // 测试中按文件名排序保证确定性
543 }
544 const timeComparison = b[1] - a[1]
545 if (timeComparison === 0) {
546 return a[0].localeCompare(b[0]) // 文件名作为 tiebreaker
547 }
548 return timeComparison
549 })

默认按修改时间降序排序——最近修改的文件排在前面。这个设计假设是:用户最可能关心的是最近活跃的文件。测试环境中改为按文件名排序,确保结果的确定性。

使用 Promise.allSettled 而非 Promise.all:当某个文件在 ripgrep 扫描和 stat 之间被删除时,不会导致整个批次失败。失败的 stat 被视为 mtime 0。

忽略模式集成

src/tools/GrepTool/GrepTool.ts:412-427
TypeScript
412 const ignorePatterns = normalizePatternsToPath(
413 getFileReadIgnorePatterns(appState.toolPermissionContext),
414 getCwd(),
415 )
416 for (const ignorePattern of ignorePatterns) {
417 const rgIgnorePattern = ignorePattern.startsWith('/')
418 ? `!${ignorePattern}`
419 : `!**/${ignorePattern}`
420 args.push('--glob', rgIgnorePattern)
421 }

权限系统中配置的 deny 规则被转换为 ripgrep 的 glob 排除模式。非绝对路径需要添加 **/ 前缀,因为 ripgrep 只对工作目录相对路径应用 gitignore 模式。


ToolSearchTool:延迟工具发现

ToolSearchTool 解决的是一个完全不同的搜索问题:当 Claude Code 有 100+ 个工具时,如何让 AI 高效地找到需要的工具?

延迟加载的动机

工具加载策略
所有工具 (100+)
"拆分策略"
核心工具
Read, Write, Edit, · Bash, Glob, Grep · 立即加载
延迟工具
MCP 工具, LSP, · WebFetch, WebSearch · 按需发现

如果把所有工具的完整 Schema 都放入初始 prompt,会消耗大量 token。ToolSearchTool 实现了一种"工具目录":延迟工具只有名字出现在 system-reminder 中,AI 需要时通过 ToolSearchTool 获取完整定义。

延迟工具判定

src/tools/ToolSearchTool/prompt.ts:62-108
TypeScript
62export function isDeferredTool(tool: Tool): boolean {
63 // Explicit opt-out via _meta['anthropic/alwaysLoad']
64 if (tool.alwaysLoad === true) return false
65
66 // MCP tools are always deferred (workflow-specific)
67 if (tool.isMcp === true) return true
68
69 // Never defer ToolSearch itself
70 if (tool.name === TOOL_SEARCH_TOOL_NAME) return false
71
72 // Agent tool must be available turn 1 in fork-first mode
73 if (feature('FORK_SUBAGENT') && tool.name === AGENT_TOOL_NAME) {
74 if (m.isForkSubagentEnabled()) return false
75 }
76
77 return tool.shouldDefer === true
78}

判定规则的优先级:

  1. alwaysLoad: true — 永不延迟(MCP 工具可通过 _meta 设置)
  2. MCP 工具 — 默认延迟(工作流特定的)
  3. ToolSearchTool 自身 — 永不延迟(用于加载其他工具的工具不能被延迟)
  4. 特殊工具(Agent、Brief)— 条件不延迟
  5. shouldDefer: true — 延迟

两种查询模式

src/tools/ToolSearchTool/ToolSearchTool.ts:21-33
TypeScript
21export const inputSchema = lazySchema(() =>
22 z.object({
23 query: z
24 .string()
25 .describe(
26 'Query to find deferred tools. Use "select:<tool_name>" for direct selection, or keywords to search.',
27 ),
28 max_results: z
29 .number()
30 .optional()
31 .default(5)
32 .describe('Maximum number of results to return (default: 5)'),
33 }),
34)

select: 模式 — 精确选择:select:Read,Edit,Grep 直接按名字获取工具。支持逗号分隔的多选。

关键词搜索 — 模糊搜索:notebook jupyter 会搜索工具名称和描述,返回最相关的结果。

关键词评分算法

src/tools/ToolSearchTool/ToolSearchTool.ts:259-301
TypeScript
259async function searchToolsWithKeywords(query, deferredTools, tools, maxResults) {
260 // ...
261 const scored = await Promise.all(
262 candidateTools.map(async tool => {
263 const parsed = parseToolName(tool.name)
264 const description = await getToolDescriptionMemoized(tool.name, tools)
265 const hintNormalized = tool.searchHint?.toLowerCase() ?? ''
266
267 let score = 0
268 for (const term of allScoringTerms) {
269 const pattern = termPatterns.get(term)!
270
271 // Exact part match (high weight for MCP server names)
272 if (parsed.parts.includes(term)) {
273 score += parsed.isMcp ? 12 : 10
274 } else if (parsed.parts.some(part => part.includes(term))) {
275 score += parsed.isMcp ? 6 : 5
276 }
277
278 // searchHint match — curated phrase, higher signal than prompt
279 if (hintNormalized && pattern.test(hintNormalized)) {
280 score += 4
281 }
282
283 // Description match - word boundary to avoid false positives
284 if (pattern.test(descNormalized)) {
285 score += 2
286 }
287 }
288
289 return { name: tool.name, score }
290 }),
291 )
292}

评分层次:

匹配类型分数 (普通)分数 (MCP)
工具名精确部分匹配1012
工具名包含匹配56
searchHint 匹配44
全名回退匹配33
描述词边界匹配22

MCP 工具的名称匹配得分更高,因为 MCP 工具名通常包含服务器名称(如 mcp__slack__send_message),按服务器名搜索是最常见的查询模式。

+ 前缀的必需词

src/tools/ToolSearchTool/ToolSearchTool.ts:223-232
TypeScript
223 const requiredTerms: string[] = []
224 const optionalTerms: string[] = []
225 for (const term of queryTerms) {
226 if (term.startsWith('+') && term.length > 1) {
227 requiredTerms.push(term.slice(1))
228 } else {
229 optionalTerms.push(term)
230 }
231 }

+slack send 表示:工具名或描述中必须包含 "slack",然后在满足条件的工具中按 "send" 相关性排序。这让搜索更精确。

工具引用返回

src/tools/ToolSearchTool/ToolSearchTool.ts:444-470
TypeScript
444 mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
445 if (content.matches.length === 0) {
446 let text = 'No matching deferred tools found'
447 if (content.pending_mcp_servers?.length > 0) {
448 text += `. Some MCP servers are still connecting: ${content.pending_mcp_servers.join(', ')}...`
449 }
450 return { type: 'tool_result', tool_use_id: toolUseID, content: text }
451 }
452 return {
453 type: 'tool_result',
454 tool_use_id: toolUseID,
455 content: content.matches.map(name => ({
456 type: 'tool_reference' as const,
457 tool_name: name,
458 })),
459 }
460 },

返回 tool_reference 类型的内容块——这是 Anthropic API 的特殊格式,告诉 API 将匹配工具的完整 Schema 注入到模型的上下文中。AI 在下一个轮次就可以使用这些工具了。

当某些 MCP 服务器仍在连接中时,返回消息会包含挂起的服务器列表,提示 AI 稍后重试。


三工具组合工作流

sequenceDiagram
    participant AI as Claude
    participant TS as ToolSearchTool
    participant Glob as GlobTool
    participant Grep as GrepTool
    participant Read as FileReadTool

    Note over AI: "帮我找到并修复 TypeScript 类型错误"

    AI->>TS: query: "select:LSP"
    TS-->>AI: LSPTool Schema 加载

    AI->>Glob: pattern: "**/*.ts"
    Glob-->>AI: 45 个 TypeScript 文件

    AI->>Grep: pattern: "// @ts-ignore", type: "ts"
    Grep-->>AI: 3 个文件包含 @ts-ignore

    AI->>Read: 读取第一个文件
    Read-->>AI: 文件内容

    Note over AI: 分析并修复类型错误

典型的使用顺序:

  1. ToolSearchTool — 如果需要特殊工具(LSP、WebFetch 等),先通过 ToolSearch 加载
  2. GlobTool — 建立文件清单,了解项目结构
  3. GrepTool — 在目标文件中搜索具体内容
  4. FileReadTool — 精读找到的文件

这个顺序从粗到细,逐步缩小搜索范围。每一步都使用了路径相对化来节省 token。


Prompt 引导

BashTool 的 prompt 明确引导 AI 使用搜索工具而非 Shell 命令:

src/tools/BashTool/prompt.ts:280-286
TypeScript
280const toolPreferenceItems = [
281 `File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
282 `Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
283]

GrepTool 自己的 prompt 也强调了这一点:

src/tools/GrepTool/prompt.ts:7-17
TypeScript
7`A powerful search tool built on ripgrep
8
9 Usage:
10 - ALWAYS use Grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a Bash command.
11 The Grep tool has been optimized for correct permissions and access.
12 - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
13 - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")
14 - Output modes: "content", "files_with_matches" (default), "count"
15 - Use Agent tool for open-ended searches requiring multiple rounds
16 - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping`

关键词:"ALWAYS" 和 "NEVER" 的明确指令,比 "prefer" 更有效地引导 AI 行为。


设计启示

Claude Code 的搜索系统体现了几个核心设计原则:

  1. 专用工具优于通用命令 — Glob/Grep 提供了比 find/grep 更好的权限控制、token 管理和格式化输出

  2. 渐进精炼 — 从 GlobTool 的粗粒度文件发现,到 GrepTool 的细粒度内容搜索,再到 FileReadTool 的完整读取,搜索工作流自然地从粗到细

  3. 延迟加载 — ToolSearchTool 让系统可以支持 100+ 工具而不消耗 100+ 工具的 prompt token,只在需要时加载

  4. Token 感知设计 — 路径相对化、结果截断、默认 head_limit、修改时间排序——每个设计决策都考虑了 token 效率