模块四:工具系统 | 前置依赖:第 10 课 | 预计学习时间:70 分钟
学习目标
完成本课后,你将能够:
- 描述 FileReadTool 如何统一处理文本、图片、PDF、Notebook 四种文件格式
- 解释 GlobTool 的模式匹配机制与修改时间排序策略
- 说明 GrepTool 基于 ripgrep 的三种输出模式及 head_limit 分页机制
- 识别三个只读工具共享的设计模式:
isReadOnly()、isConcurrencySafe()、权限检查委托
11.1 只读工具的共同特征
在深入每个工具之前,先看它们的共性。三个只读工具都实现了 buildTool() 返回的 ToolDef 接口,并且共享以下关键行为:
┌────────────────────────────────────────────────────────┐
│ 只读工具共同特征 │
├────────────────────────────────────────────────────────┤
│ │
│ isReadOnly() → true 不修改任何文件 │
│ isConcurrencySafe() → true 可以并发执行 │
│ checkPermissions() 委托给 checkReadPermission │
│ validateInput() 执行前校验参数合法性 │
│ expandPath() 统一路径规范化 │
│ toRelativePath() 输出路径节省 token │
│ UNC 路径安全检查 防止 NTLM 凭据泄漏 │
│ │
└────────────────────────────────────────────────────────┘
并发安全
isConcurrencySafe() 返回 true 意味着 Claude 可以在同一轮响应中并行发起多个只读工具调用。例如同时 Grep 搜索三个目录、同时 Read 两个文件 —— Agent 循环会并行执行它们。
权限检查委托
三个工具的权限检查都遵循同一模式:
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
ThisTool, // 工具自身引用
input, // 用户输入
appState.toolPermissionContext, // 当前权限上下文
)
}
checkReadPermissionForTool 是一个共享的权限检查函数,它会根据当前权限模式(default/plan/bypass 等)决定是自动允许还是需要用户确认。
11.2 FileReadTool — 万能文件阅读器
文件位置
tools/FileReadTool/
├── FileReadTool.ts # 核心逻辑(~600 行)
├── imageProcessor.ts # 图片处理(sharp/napi 适配)
├── limits.ts # 读取限制配置
├── prompt.ts # 提示词模板
└── UI.tsx # 终端渲染组件
输入参数
FileReadTool 的输入通过 Zod schema 严格定义:
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z.string(), // 必须是绝对路径
offset: z.number().int().nonnegative().optional(), // 起始行号
limit: z.number().int().positive().optional(), // 读取行数
pages: z.string().optional(), // PDF 页码范围,如 "1-5"
}),
)
注意 lazySchema() 的使用 —— 这是一个延迟求值包装器,避免模块加载时就构建 schema。
输出类型:六种联合类型
FileReadTool 的输出是一个 discriminated union,通过 type 字段区分六种情况:
┌──────────────────────────────────────────────────────┐
│ FileReadTool 输出类型 │
├──────────────────────────────────────────────────────┤
│ │
│ type: "text" 普通文本文件 │
│ ├── content: string 带行号的文件内容 │
│ ├── numLines: number 返回行数 │
│ ├── startLine: number 起始行号 │
│ └── totalLines: number 文件总行数 │
│ │
│ type: "image" 图片文件 │
│ ├── base64: string Base64 编码数据 │
│ ├── type: "image/jpeg" | "image/png" | ... │
│ └── dimensions: { originalWidth, displayWidth, ... } │
│ │
│ type: "notebook" Jupyter Notebook │
│ └── cells: Cell[] 所有 cell 内容 │
│ │
│ type: "pdf" 小 PDF(直接发送) │
│ └── base64: string 完整 PDF 数据 │
│ │
│ type: "parts" 大 PDF(分页提取) │
│ ├── count: number 提取页数 │
│ └── outputDir: string 提取后的图片目录 │
│ │
│ type: "file_unchanged" 文件未修改(去重) │
│ └── filePath: string 文件路径 │
│ │
└──────────────────────────────────────────────────────┘
读取限制系统
limits.ts 定义了两层限制机制:
| 限制 | 默认值 | 检查时机 | 溢出行为 |
|---|---|---|---|
| maxSizeBytes | 256 KB | 读取前(stat) | 抛出错误 |
| maxTokens | 25,000 | 读取后(API 计数) | 抛出错误 |
限制值的优先级链:
环境变量 CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS
↓ (未设置时)
GrowthBook 远程配置 (tengu_amber_wren)
↓ (未设置时)
DEFAULT_MAX_OUTPUT_TOKENS (25000)
getDefaultFileReadingLimits() 使用 memoize() 缓存,确保会话内限制值不会因 GrowthBook 后台刷新而变化。
文件去重机制
这是一个精妙的优化。当 Claude 在同一会话中多次读取同一个文件时,FileReadTool 会检测文件是否变化:
// 检查 readFileState 中的缓存记录
const existingState = readFileState.get(fullFilePath)
if (existingState && !existingState.isPartialView && existingState.offset !== undefined) {
const rangeMatch = existingState.offset === offset && existingState.limit === limit
if (rangeMatch) {
const mtimeMs = await getFileModificationTimeAsync(fullFilePath)
if (mtimeMs === existingState.timestamp) {
// 文件未变化,返回简短的 stub 而非完整内容
return {
data: { type: 'file_unchanged', file: { filePath: file_path } }
}
}
}
}
返回的 stub 消息只有约 100 字节,而完整文件内容可能有 25K token。源码注释中提到,BQ 代理数据显示 约 18% 的 Read 调用是同文件重复读取,这个优化直接减少了 cache_creation token 消耗。
图片处理管道
imageProcessor.ts 展示了一个优雅的动态加载模式:
export async function getImageProcessor(): Promise<SharpFunction> {
if (imageProcessorModule) {
return imageProcessorModule.default // 缓存命中
}
if (isInBundledMode()) {
try {
// 优先使用原生 NAPI 图片处理器
const imageProcessor = await import('image-processor-napi')
const sharp = imageProcessor.sharp || imageProcessor.default
imageProcessorModule = { default: sharp }
return sharp
} catch {
// 降级到 sharp
console.warn('Native image processor not available, falling back to sharp')
}
}
// 非打包模式或降级使用 sharp
const imported = await import('sharp')
// ...
}
这里体现了两个设计策略:
- 原生优先:打包模式下优先使用
image-processor-napi(性能更好) - 优雅降级:原生模块不可用时自动回退到
sharp
被阻止的设备路径
FileReadTool 维护了一个被阻止的设备路径集合,防止进程挂起:
const BLOCKED_DEVICE_PATHS = new Set([
'/dev/zero', // 无限输出
'/dev/random', // 无限输出
'/dev/urandom', // 无限输出
'/dev/stdin', // 阻塞等待输入
'/dev/tty', // 阻塞等待输入
'/dev/console', // 阻塞等待输入
'/dev/stdout', // 读取无意义
'/dev/stderr', // 读取无意义
])
macOS 截图路径兼容
一个有趣的边缘案例处理 —— macOS 截图文件名中 AM/PM 前的空格字符因系统版本不同可能是普通空格或窄不换行空格 (U+202F):
function getAlternateScreenshotPath(filePath: string): string | undefined {
const filename = path.basename(filePath)
const amPmPattern = /^(.+)([ \u202F])(AM|PM)(\.png)$/
const match = filename.match(amPmPattern)
if (!match) return undefined
const currentSpace = match[2]
const alternateSpace = currentSpace === ' ' ? THIN_SPACE : ' '
return filePath.replace(/* ... */)
}
11.3 GlobTool — 文件模式匹配
文件位置
tools/GlobTool/
├── GlobTool.ts # 核心逻辑(~198 行)
├── prompt.ts # 工具描述
└── UI.tsx # 渲染组件
设计简洁性
GlobTool 是三个只读工具中最简洁的,只有两个输入参数:
const inputSchema = lazySchema(() =>
z.strictObject({
pattern: z.string(), // glob 模式,如 "**/*.ts"
path: z.string().optional(), // 搜索根目录,默认 cwd
}),
)
核心执行流程
用户输入 pattern + path
│
▼
validateInput() ─── 验证 path 存在且是目录
│
▼
glob(pattern, path, { limit, offset }, signal, permissionContext)
│ │
│ └── 100 条结果限制
▼
files.map(toRelativePath) ── 转为相对路径节省 token
│
▼
返回 { filenames, durationMs, numFiles, truncated }
关键特性
结果限制:默认最多返回 100 个文件匹配结果。如果超过,truncated 标志设为 true,模型会收到提示"Consider using a more specific path or pattern."
修改时间排序:GlobTool 本身依赖底层 glob() 工具函数的实现,返回的文件按修改时间排序。这让 Claude 优先看到最近修改的文件,通常这些文件与当前任务最相关。
路径相对化:
const filenames = files.map(toRelativePath)
绝对路径 /Users/oker/project/src/index.ts 变成 src/index.ts,在长列表中显著节省 token。
UI 复用
一个值得注意的细节 —— GlobTool 的 renderToolResultMessage 复用了 GrepTool 的实现:
// UI.tsx
export const renderToolResultMessage = GrepTool.renderToolResultMessage
因为两者的结果展示格式相似(文件列表 + 统计),没有必要重复实现。
11.4 GrepTool — 基于 ripgrep 的内容搜索
文件位置
tools/GrepTool/
├── GrepTool.ts # 核心逻辑(~577 行)
├── prompt.ts # 工具描述
└── UI.tsx # 渲染组件
丰富的输入参数
GrepTool 是三个只读工具中参数最多的:
z.strictObject({
pattern: z.string(), // 正则表达式
path: z.string().optional(), // 搜索路径
glob: z.string().optional(), // 文件过滤模式
output_mode: z.enum([ // 三种输出模式
'content', // 显示匹配行内容
'files_with_matches', // 只显示文件路径(默认)
'count', // 显示匹配计数
]).optional(),
'-B': z.number().optional(), // 匹配前上下文行数
'-A': z.number().optional(), // 匹配后上下文行数
'-C': z.number().optional(), // 上下文行数(-C 别名)
context: z.number().optional(), // 上下文行数
'-n': z.boolean().optional(), // 显示行号(默认 true)
'-i': z.boolean().optional(), // 忽略大小写
type: z.string().optional(), // 文件类型过滤
head_limit: z.number().optional(), // 结果数量限制
offset: z.number().optional(), // 结果偏移量
multiline: z.boolean().optional(), // 多行匹配模式
})
三种输出模式
┌───────────────────────────────────────────────────────────┐
│ GrepTool 输出模式 │
├───────────────────────────────────────────────────────────┤
│ │
│ files_with_matches (默认) │
│ ┌─────────────────────────┐ │
│ │ src/utils/file.ts │ 只返回文件路径 │
│ │ src/tools/BashTool.tsx │ 按修改时间排序 │
│ │ tests/file.test.ts │ 相对路径节省 token │
│ └─────────────────────────┘ │
│ │
│ content │
│ ┌─────────────────────────┐ │
│ │ src/utils/file.ts:42: │ 显示文件名:行号:内容 │
│ │ export function foo() │ 支持 -A/-B/-C 上下文 │
│ │ src/utils/file.ts:85: │ 支持 -n 行号显示 │
│ │ return foo() │ │
│ └─────────────────────────┘ │
│ │
│ count │
│ ┌─────────────────────────┐ │
│ │ src/utils/file.ts:3 │ 文件名:匹配次数 │
│ │ src/tools/BashTool.tsx:1│ 汇总总匹配数和文件数 │
│ └─────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────┘
head_limit 分页机制
GrepTool 实现了一个精心设计的分页系统:
const DEFAULT_HEAD_LIMIT = 250 // 默认限制
function applyHeadLimit<T>(
items: T[],
limit: number | undefined,
offset: number = 0,
): { items: T[]; appliedLimit: number | undefined } {
// 显式传入 0 = 不限制(逃生舱口)
if (limit === 0) {
return { items: items.slice(offset), appliedLimit: undefined }
}
const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT
const sliced = items.slice(offset, offset + effectiveLimit)
// 只在实际发生截断时才报告 appliedLimit
const wasTruncated = items.length - offset > effectiveLimit
return {
items: sliced,
appliedLimit: wasTruncated ? effectiveLimit : undefined,
}
}
源码注释解释了为什么需要这个限制:
无限制的 content 模式 grep 可以填满 20KB 的持久化阈值(每个 grep 密集会话约 6-24K token)。250 对探索性搜索来说已经足够慷慨,同时防止上下文膨胀。
ripgrep 调用构建
GrepTool 的核心是构建 ripgrep 命令行参数:
const args = ['--hidden']
// 排除版本控制目录
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
args.push('--glob', `!${dir}`) // .git, .svn, .hg, .bzr, .jj, .sl
}
// 限制行长度,防止 base64/压缩内容污染输出
args.push('--max-columns', '500')
// 多行匹配
if (multiline) {
args.push('-U', '--multiline-dotall')
}
// 构建忽略模式(来自权限系统)
const ignorePatterns = normalizePatternsToPath(
getFileReadIgnorePatterns(appState.toolPermissionContext),
getCwd(),
)
for (const ignorePattern of ignorePatterns) {
const rgIgnorePattern = ignorePattern.startsWith('/')
? `!${ignorePattern}`
: `!**/${ignorePattern}`
args.push('--glob', rgIgnorePattern)
}
注意几个安全细节:
- 以
-开头的 pattern 会用-e标志包裹,防止被 ripgrep 解释为命令行选项 - 版本控制目录自动排除(
.git,.svn,.hg等) - 行长度限制为 500 字符,防止 base64 或 minified 内容污染搜索结果
files_with_matches 模式的修改时间排序
当使用默认模式时,GrepTool 会对匹配的文件按修改时间排序:
const stats = await Promise.allSettled(
results.map(_ => getFsImplementation().stat(_)),
)
const sortedMatches = results
.map((_, i) => {
const r = stats[i]!
return [_, r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0] as const
})
.sort((a, b) => {
if (process.env.NODE_ENV === 'test') {
return a[0].localeCompare(b[0]) // 测试环境按文件名排序,保证确定性
}
const timeComparison = b[1] - a[1]
if (timeComparison === 0) {
return a[0].localeCompare(b[0]) // 时间相同按文件名
}
return timeComparison
})
使用 Promise.allSettled 而非 Promise.all,这样即使某个文件在 ripgrep 扫描后被删除(stat 失败),也不会影响其他结果。
11.5 工具结果序列化
三个工具都实现了 mapToolResultToToolResultBlockParam(),负责将结构化输出转换为 Claude API 能理解的 tool_result 格式。
GlobTool 的序列化
mapToolResultToToolResultBlockParam(output, toolUseID) {
if (output.filenames.length === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No files found',
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
...output.filenames,
...(output.truncated
? ['(Results are truncated. Consider using a more specific path or pattern.)']
: []),
].join('\n'),
}
}
GrepTool 的序列化(content 模式)
if (mode === 'content') {
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
const resultContent = content || 'No matches found'
const finalContent = limitInfo
? `${resultContent}\n\n[Showing results with pagination = ${limitInfo}]`
: resultContent
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: finalContent,
}
}
当结果被截断时,分页信息会附加在末尾,让模型知道可以使用 offset 参数查看更多结果。
11.6 搜索文本提取
三个工具都实现了 extractSearchText(),用于全文搜索索引:
| 工具 | extractSearchText 行为 |
|---|---|
| FileReadTool | 返回空字符串 —— 文件内容不索引(避免重复) |
| GlobTool | 返回 filenames.join('\n') —— 索引文件路径 |
| GrepTool | content 模式返回内容,其他模式返回文件路径 |
FileReadTool 返回空字符串的原因在源码注释中有说明:
UI.tsx:140 — ALL types render summary chrome only: "Read N lines", "Read image (42KB)". Never the content itself. The model-facing serialization sends content; UI shows none of it. Nothing to index.
课后练习
练习 1:追踪一次图片读取
假设用户说"帮我看看 /tmp/screenshot.png",从 FileReadTool.call() 开始追踪:
- 文件类型如何被识别?
- 图片如何被压缩和编码?
- 最终发送给 Claude API 的数据结构是什么样的?
练习 2:实现一个自定义只读工具
参考 GlobTool 的结构,设计一个 TreeTool(返回目录树),需要包含:
- inputSchema(root_path、max_depth、include_hidden)
- outputSchema(tree_string、numDirs、numFiles)
- isReadOnly() 返回 true
练习 3:GrepTool 分页实验
考虑以下场景:一个项目中有 1000 个文件匹配 import。用 head_limit=50 和 offset=0 做第一次搜索,然后用 offset=50 做第二次。
- 两次调用的
appliedLimit分别是什么? - 如果第二次调用的 offset=950,返回 50 个结果,
appliedLimit是多少? - 如果传入 head_limit=0 会发生什么?
练习 4:分析去重收益
FileReadTool 的去重机制(file_unchanged stub)用约 100 字节替代了可能 25K token 的完整内容。源码提到 18% 的 Read 调用是重复的。计算一下:如果一个会话中有 50 次 Read 调用,每次平均 10K token,去重能节省多少 token?
本课小结
| 要点 | 内容 |
|---|---|
| 共同特征 | isReadOnly=true, isConcurrencySafe=true, 共享权限检查委托 |
| FileReadTool | 六种输出类型联合体,支持文本/图片/PDF/Notebook,去重优化节省 18% 重复读取 |
| GlobTool | 最简洁的工具(~200 行),修改时间排序,100 条限制,复用 GrepTool 的 UI |
| GrepTool | 基于 ripgrep,三种输出模式,head_limit=250 分页防止上下文膨胀 |
| 安全机制 | UNC 路径检查、设备路径阻止、VCS 目录排除、行长度限制 |
| 性能优化 | 路径相对化节省 token、去重 stub、Promise.allSettled 容错 |
下一课预告
第 12 课:写入工具 — FileEdit, FileWrite — 我们将进入"有副作用"的工具领域。FileEditTool 如何保证 old_string 的唯一性?如何处理花引号规范化?FileWriteTool 为什么强制"先读后写"?以及 speculation(乐观更新)状态追踪如何工作?