添加工具不改循环:揭秘 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)
} else if (block.name === 'read_file') {
output = runRead(block.input.path)
} else if (block.name === 'write_file') {
output = runWrite(block.input.path, block.input.content)
} else if (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 能读取任意文件,同样危险。
所以我们需要沙箱:
// 路径沙箱:防止逃逸工作目录
export function safePath(relativePath: string): string {
const absolutePath = path.resolve(WORKDIR, relativePath)
if (!absolutePath.startsWith(WORKDIR)) {
throw new Error(`Path escapes workspace: ${relativePath}`)
}
return absolutePath
}
逻辑很简单:
- 把相对路径转成绝对路径
- 检查是否还在工作目录内
- 不在?拒绝执行
这样,模型想读取 /etc/passwd 或 ../../../secret,都会被挡住。
四个工具的实现
bash:执行命令
export const runBash: ToolHandler = (input) => {
const command = input.command as string
// 危险命令检查
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:读取文件
export const runRead: ToolHandler = async (input) => {
const filePath = safePath(input.path as string) // 沙箱检查
const content = await fs.readFile(filePath, 'utf-8')
// 限制输出长度,防止上下文爆炸
return content.slice(0, 50000)
}
write_file:写入文件
export const runWrite: ToolHandler = async (input) => {
const filePath = safePath(input.path as string) // 沙箱检查
const content = input.content as string
// 自动创建目录
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:精确替换
export const runEdit: ToolHandler = async (input) => {
const filePath = safePath(input.path as string)
const oldText = input.old_text as string
const newText = input.new_text as string
const 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_file
s02 >> 创建一个 hello.txt,内容是 "Hello s02"
> write_file
Wrote 9 bytes to hello.txt
已成功创建 hello.txt 文件。
s02 >> 读取 hello.txt
> read_file
Hello s02
s02 >> 把 hello.txt 里的 s02 改成 World
> edit_file
Edited 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 有规划能力
🚀 写在最后
本文是 《从 0 到 1 构建 Claude Code》 系列专栏的第二篇。我们将持续深度拆解 Agentic Programming 的核心机制。公众号合集
如果你对 LLM 原生开发、TypeScript 架构设计 感兴趣,欢迎关注我的公众号,我们一起在 AI 时代完成技术进化。
![]()
长按二维码关注:前端的AI野心
相关阅读: