# Step 15: Glob 搜索工具
一句话导读:给 AI 一个"按通配符找文件"的能力——
**/*.ts一把梭。看似只是包了个fast-glob,但为什么不直接让 AI 调find、为什么工具要有统一的 schema,才是这一步真正的考点。
# 一、这一步做了什么(What)
在 step14(已有 Bash / Read / Write)的基础上,新增第四个真实工具 GlobTool:
- 新建
tools/glob.ts,基于fast-glob库实现按模式搜文件; package.json加入fast-glob依赖;index.ts注册GlobTool,工具总数来到 5 个。
工具输入只有一个参数 pattern(如 **/*.ts),输出是匹配到的文件路径列表,一行一个。核心实现只有约 40 行。
# 二、面试官视角:为什么要做?(Why)
面试题:AI 已经有 Bash 工具了,
find/ls张口就来,为什么还要单独做一个 Glob 工具?这不是重复造轮子吗?
这题在考你是否理解"工具作为一等公民"的设计哲学。理由有四层:
| 维度 | 让 AI 调 find | 给它专门的 Glob 工具 |
|---|---|---|
| 输入输出 | 自由文本,格式随平台/参数变 | 固定 {pattern} 进、路径列表 出,可被程序解析 |
| 跨平台 | find 在 Windows 不存在,语法各异 | Node 库,一份代码全平台一致 |
| 可控性 | 行为取决于系统装了什么版本 | 行为由我们的代码决定,可加排除/上限 |
| 权限 | Bash 是"能执行任意命令"的核弹级权限 | 只读文件名,是最小权限的安全动作 |
最后一条最关键:Bash 权限太大。当后面接入权限沙箱(step19),一个"只列文件名"的动作如果走 Bash,就得和 rm -rf 走同一道高危审批。把它拆成独立的、语义明确的窄工具,才能给它单独的、宽松的信任级别。窄工具 = 可被精细授权的工具。
# 三、原理:它是怎么工作的(How)
# 真实代码
tools/glob.ts 的核心就是一个对象,把 fast-glob 包成统一的 Tool 接口:
export const GlobTool = {
name: 'Glob',
description: '按通配符模式搜索文件,如 **/*.ts',
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string', description: '搜索模式,如 **/*.ts' },
},
required: ['pattern'],
},
async execute(input: ToolInput): Promise<ToolResult> {
const pattern = input.pattern as string;
if (!pattern) {
return { content: [{ type: 'text', text: '错误: 未指定搜索模式' }], isError: true };
}
try {
const files = fg.sync(pattern, { dot: true }); // ★ 一行完成搜索
if (files.length === 0) {
return { content: [{ type: 'text', text: '未找到匹配的文件: ' + pattern }] };
}
return { content: [{ type: 'text', text: files.join('\n') }] };
} catch (err: any) {
return { content: [{ type: 'text', text: '搜索失败: ' + (err.message || String(err)) }], isError: true };
}
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
三个部分值得注意:
inputSchema(JSON Schema):这不是给人看的,是发给 Claude API 的。模型正是靠它知道"有个叫 Glob 的工具、要传一个 pattern 字符串",从而自己决定调用。工具的"自我描述"是 Tool Calling 的前提。fg.sync(pattern, { dot: true }):dot: true让它匹配.env、.gitignore这种点开头的隐藏文件,否则默认会漏掉。- 返回结构
{ content, isError }:所有工具统一的返回形状。空结果不算错误(isError不置位),只有异常才isError: true。这个"结构化返回"让上层循环能统一处理成功/失败。
# 数据流
AI 想找所有 TS 文件
↓ 生成 tool_use { name:"Glob", input:{ pattern:"**/*.ts" } }
GlobTool.execute({ pattern:"**/*.ts" })
↓ fg.sync("**/*.ts", { dot:true })
["src/a.ts", "src/b.ts", ...]
↓ files.join("\n")
{ content:[{ type:"text", text:"src/a.ts\nsrc/b.ts" }] }
↓ 作为 tool_result 回传
AI 拿到文件清单,决定下一步(比如 Read 其中某个)
2
3
4
5
6
7
8
9
# 为什么选 fast-glob 而不是自己递归遍历目录?
自己写 readdirSync 递归,你得手动处理:** 跨层匹配、{a,b} 花括号展开、! 取反、软链接、gitignore……这些边角 fast-glob 都做过了且性能优化过。工具层的价值在"接口统一",不在"重写文件系统遍历"——底层能力交给成熟库,我们只负责把它适配成标准 Tool 形状。
# 四、深入追问(面试常见 follow-up)
Q:inputSchema 写得好不好,对 AI 调用有影响吗?
A:影响极大。schema 里的 description 和参数说明就是模型的"使用说明书"。如果 pattern 的 description 写成"搜索模式"太含糊,模型可能传成正则或裸文件名;写成"glob 通配符,如 **/*.ts"并给例子,模型命中率明显更高。Prompt engineering 有一半发生在工具描述里。
Q:如果匹配到几万个文件,直接 join('\n') 全塞回去会怎样?
A:会把上下文窗口撑爆——一次工具调用就可能吞掉几万 token。我们这个教学版没设上限,这正是它和真实 GlobTool 的差距:真实版有 limit / 最大结果数,超出会截断并提示"结果过多,请缩小范围"。任何返回不定长内容的工具,都必须有截断策略(step16 的 Grep 就补上了每文件 5 行的上限)。
Q:fg.sync 是同步的,会不会阻塞事件循环?后面 step18 要并行工具怎么办?
A:会阻塞。sync 版本在遍历期间独占主线程,如果目录巨大,其他并行工具其实是被它卡住的。生产实现应该用 fg() 的异步/流式版本。这是教学简化里一个真实的性能隐患——面试时能指出"sync 破坏了 step18 的并行性"是加分项。
Q:Glob 只找文件名,Grep 找文件内容,AI 怎么知道该用哪个?
A:靠 description 区分语义边界——"按模式搜文件(找路径)" vs "在文件中搜文本(找内容)"。实践中模型常常先 Glob 缩小文件范围、再 Grep 内容,两者是接力关系。这也是为什么它们要拆成两个工具而非一个万能搜索。
# 五、踩坑 / 设计权衡
dot: true容易漏配:不加它,AI 说"帮我看看配置"却搜不到.env,会误判"项目没有配置文件"。默认包含隐藏文件更符合编程助手的直觉。syncvsasync的取舍:教学版图代码简单用了sync,牺牲了并发性。真实系统一定用异步。- 没有工作区边界:当前实现能匹配到
../之外的任意路径。真实版会限定在工作目录内,防止 AI 把整个磁盘扫一遍。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
tools/glob.ts ~40 行 | src/tools/GlobTool/(3 个文件) |
只有 pattern 参数 | 还有排除模式、大小写控制、最大结果数 |
fg.sync 同步遍历 | 异步 + 结果上限 + 按修改时间排序 |
| 无工作区限制 | 限定在工作目录内,路径安全校验 |
| 空结果返回提示 | 同样区分"无匹配"与"出错" |
# 七、一句话总结
Glob 工具 = 把 fast-glob 包成统一 Tool 接口的窄工具:它存在的意义不是"AI 不会用 find",而是要一个跨平台、输出可解析、能被单独授权、可加截断上限的"找文件"原语——窄而明确的工具,才是可控 Agent 的地基。
# 下一节预告
Glob 找到了文件,下一步 step16 的 Grep 工具负责在文件内容里搜文本,并第一次引入"结果截断"这个必修课。