# Step 15: Glob 搜索工具

一句话导读:给 AI 一个"按通配符找文件"的能力——**/*.ts 一把梭。看似只是包了个 fast-glob,但为什么不直接让 AI 调 find、为什么工具要有统一的 schema,才是这一步真正的考点。


# 一、这一步做了什么(What)

在 step14(已有 Bash / Read / Write)的基础上,新增第四个真实工具 GlobTool:

  1. 新建 tools/glob.ts,基于 fast-glob 库实现按模式搜文件;
  2. package.json 加入 fast-glob 依赖;
  3. 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 };
    }
  },
};
1
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 其中某个)
1
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,会误判"项目没有配置文件"。默认包含隐藏文件更符合编程助手的直觉。
  • sync vs async 的取舍:教学版图代码简单用了 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 工具负责在文件内容里搜文本,并第一次引入"结果截断"这个必修课。