AI Agent 的工程之道:为什么核心循环只有 20 行?

68 阅读5分钟

添加工具不改循环:揭秘 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,找到就执行。

添加工具:

  1. 在 TOOLS 加一个定义
  2. 在 HANDLERS 加一个 handler
  3. 循环代码不动

安全第一:路径沙箱

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
}

逻辑很简单:

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

这样,模型想读取 /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

组件s01s02
工具数量1 (bash)4 (bash + 文件操作)
工具调用硬编码 bashDispatch 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 有规划能力

项目地址:github.com/OPBR/build-…

🚀 写在最后

本文是 《从 0 到 1 构建 Claude Code》 系列专栏的第二篇。我们将持续深度拆解 Agentic Programming 的核心机制。公众号合集

如果你对 LLM 原生开发、TypeScript 架构设计 感兴趣,欢迎关注我的公众号,我们一起在 AI 时代完成技术进化。

前端的AI野心公众号
长按二维码关注:前端的AI野心

相关阅读