添加工具不改循环:揭秘 AI Agent 的 Dispatch Map 模式
是不是每加一个工具就要改一堆代码?是不是要写一堆 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,找到就执行。
添加工具:
-
在 TOOLS 加一个定义 -
在 HANDLERS 加一个 handler -
循环代码不动
安全第一:路径沙箱
bash 工具能执行任意命令,这很危险。read_file 能读取任意文件,同样危险。
所以我们需要沙箱:
// 路径沙箱:防止逃逸工作目录exportfunctionsafePath(relativePath: string): string{const absolutePath = path.resolve(WORKDIR, relativePath)if (!absolutePath.startsWith(WORKDIR)) {thrownewError(`Path escapes workspace: ${relativePath}`) }return absolutePath}
逻辑很简单:
-
把相对路径转成绝对路径 -
检查是否还在工作目录内 -
不在?拒绝执行
这样,模型想读取 /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(0, 50000)}
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
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
循环没变。只加了 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
你在设计工具时遇到过什么坑?评论区聊聊!
相关阅读:
-
30分钟手写一个 AI Agent:揭秘 Claude Code 的核心循环 -
Claude Code 官方文档 -
learn-claude-code – Python 实现
夜雨聆风