乐于分享
好东西不私藏

添加工具不改循环:揭秘 AI Agent 的 Dispatch Map 模式

添加工具不改循环:揭秘 AI Agent 的 Dispatch Map 模式

你有没有想过:为什么 Claude Code 能同时执行 bash 命令、读取文件、写入文件、编辑代码,而核心循环却永远只有那 20 行?

是不是每加一个工具就要改一堆代码?是不是要写一堆 if/else 判断?

都不是。

今天,我用 10 分钟带你理解 AI Agent 最优雅的设计模式:Dispatch Map。读完这篇,你会明白:添加工具 = 添加一个 handler,循环永远不变。


一个让人困惑的问题

在 s01 里,我们写了一个只有 bash 工具的最小 Agent。现在的问题是:

如果要添加 read_file、write_file、edit_file,代码该怎么改?

很多人的直觉是:

// ❌ 错误思路:硬编码每个工具if (block.name === 'bash') {  output = runBash(block.input.command)elseif (block.name === 'read_file') {  output = runRead(block.input.path)elseif (block.name === 'write_file') {  output = runWrite(block.input.path, block.input.content)elseif (block.name === 'edit_file') {  output = runEdit(...)}// 加新工具?继续写 else if...

这看起来很自然。但问题来了:

  • 每加一个工具,就要改循环代码
  • 循环越来越臃肿
  • 工具多了以后,代码变成面条

这不是工程。这是堆砌。


用一个类比理解

想象你是一家餐厅的经理:

硬编码模式:每个菜品都写一套流程。新菜品?改整个厨房流程。

Dispatch Map 模式:厨房流程不变。每个菜品有一个”制作卡”。厨师只需要看卡,按卡执行。

订单来了 → 查菜单 → 找制作卡 → 按卡做菜 → 出菜        菜品名 → 制作卡(一张映射表)        ═════════════════════════════        红烧肉  → 红烧肉制作卡        炒青菜  → 炒青菜制作卡        新菜品  → 新制作卡(流程不变)

代码里的”制作卡”,就是一个字典:{ tool_name: handler_function }


Dispatch Map:一张分发表

核心模式:

// 工具定义:告诉模型有什么工具const TOOLS = [  { name: 'bash', description: '...', input_schema: {...} },  { name: 'read_file', description: '...', input_schema: {...} },  { name: 'write_file', description: '...', input_schema: {...} },  { name: 'edit_file', description: '...', input_schema: {...} },]// Handler 映射:工具名 → 执行函数const HANDLERS = {'bash': runBash,'read_file': runRead,'write_file': runWrite,'edit_file': runEdit,}// 循环:永远不变for (const block of response.content) {if (block.type === 'tool_use') {const handler = HANDLERS[block.name]  // 查表const output = await handler(block.input)  // 执行    results.push({ type'tool_result', content: output })  }}

关键洞察:

模型说”我要 read_file”,代码去表里找 runRead,找到就执行。

添加工具:

  1. 在 TOOLS 加一个定义
  2. 在 HANDLERS 加一个 handler
  3. 循环代码不动
你可以理解为简单的策略模式而已

安全第一:路径沙箱

bash 工具能执行任意命令,这很危险。read_file 能读取任意文件,同样危险。

所以我们需要沙箱:

// 路径沙箱:防止逃逸工作目录exportfunctionsafePath(relativePath: string): string{const absolutePath = path.resolve(WORKDIR, relativePath)if (!absolutePath.startsWith(WORKDIR)) {thrownewError(`Path escapes workspace: ${relativePath}`)  }return absolutePath}

逻辑很简单:

  1. 把相对路径转成绝对路径
  2. 检查是否还在工作目录内
  3. 不在?拒绝执行

这样,模型想读取 /etc/passwd 或 ../../../secret,都会被挡住。


四个工具的实现

bash:执行命令

exportconst runBash: ToolHandler = (input) => {const command = input.command asstring// 危险命令检查const DANGEROUS = ['rm -rf /''sudo''shutdown''reboot']for (const d of DANGEROUS) {if (command.includes(d)) {return'Error: Dangerous command blocked'    }  }// 执行const result = execSync(command, { cwd: WORKDIR, timeout: 120000 })return result.trim() || '(no output)'}

read_file:读取文件

exportconst runRead: ToolHandler = async (input) => {const filePath = safePath(input.path asstring)  // 沙箱检查const content = await fs.readFile(filePath, 'utf-8')// 限制输出长度,防止上下文爆炸return content.slice(050000)}

write_file:写入文件

exportconst runWrite: ToolHandler = async (input) => {const filePath = safePath(input.path asstring)  // 沙箱检查const content = input.content asstring// 自动创建目录await fs.mkdir(path.dirname(filePath), { recursive: true })await fs.writeFile(filePath, content, 'utf-8')return`Wrote ${content.length} bytes to ${input.path}`}

edit_file:精确替换

exportconst runEdit: ToolHandler = async (input) => {const filePath = safePath(input.path asstring)const oldText = input.old_text asstringconst newText = input.new_text asstringconst content = await fs.readFile(filePath, 'utf-8')// 精确匹配:找不到就报错if (!content.includes(oldText)) {return`Error: Text not found in ${input.path}`  }// 只替换第一次出现const newContent = content.replace(oldText, newText, 1)await fs.writeFile(filePath, newContent, 'utf-8')return`Edited ${input.path}`}

每个工具都有明确边界:能做什么、不能做什么。


动手试试

运行 s02:

pnpm run s02

你会看到:

╔════════════════════════════════════╗║  s02 - Tool Use                    ║║  "Add tools = add a handler"       ║╚════════════════════════════════════╝Tools: bash, read_file, write_file, edit_files02 >> 创建一个 hello.txt,内容是 "Hello s02"> write_fileWrote 9 bytes to hello.txt已成功创建 hello.txt 文件。s02 >> 读取 hello.txt> read_fileHello s02s02 >> 把 hello.txt 里的 s02 改成 World> edit_fileEdited hello.txt

模型自动选择正确的工具,代码只是执行。


对比 s01 和 s02

组件
s01
s02
工具数量
1 (bash)
4 (bash + 文件操作)
工具调用
硬编码 bash
Dispatch Map
循环代码
20 行
还是 20 行
安全机制
命令黑名单
命令黑名单 + 路径沙箱

循环没变。只加了 handler 和 schema。


FAQ

Q:为什么不把所有工具都加进去?

A:渐进式学习。先理解 dispatch map 模式,再理解每个工具的设计。你会发现,加工具从不需要改循环。

Q:edit_file 为什么只替换第一次出现?

A:防止意外修改。模型应该精确指定要替换的内容。如果想替换所有,可以多次调用或用正则。

Q:safePath 能防止所有攻击吗?

A:不能。它只防止路径逃逸。符号链接、权限问题还需要其他机制。但这已经覆盖了最常见的风险。

Q:handler 可以是异步的吗?

A:完全可以。ToolHandler 类型支持 string | Promise<string>。异步操作(网络请求、大文件)都能处理。


小结

今天我们实现了:

  • ✅ 理解了 Dispatch Map 模式
  • ✅ 实现了 4 个工具(bash + 文件操作)
  • ✅ 理解了路径沙箱的作用
  • ✅ 跑起来了一个能读写文件的 Agent

关键洞察:

添加工具 = 添加 handler + schema。循环永远不变。

这就是 Harness Engineering 的核心原则:扩展不修改。


下一步

想继续深入?

  • 阅读源码:src/core/tools.ts 只有 80 行
  • 动手改造:尝试添加一个新工具(比如 grep_file
  • 阅读 s03:看看如何让 Agent 有规划能力

项目地址:https://github.com/OPBR/build-claude-code


你在设计工具时遇到过什么坑?评论区聊聊!

相关阅读