第2课:给Agent加工具 —— dispatch map模式详解

1 阅读10分钟

系列导读

这是**《12课拆解Claude Code架构》**系列的第 2 课。

上一课我们用 20 行 Python 造出了一个能操作真实世界的 Agent:一个 while True 循环 + 一个 Bash 工具。那个循环是所有 Agent 的骨架,后面 11 课都不会碰它一行。

第 2 课的格言:

"加一个工具,只加一个 handler" —— 循环不用动,新工具注册进 dispatch map 就行。


Bash 万能,但不够好

第 1 课只有一个 bash 工具。理论上 Bash 能做一切——读文件用 cat,写文件用 echo >,编辑文件用 sed。但 "能做" 和 "做得好" 之间差着三个坑:

坑一:cat 截断不可预测。 文件太大时,模型收到的是截断后的内容,但它不知道被截了多少。它以为看到了全部,实际只看了一半。后续决策建立在不完整的信息上。

坑二:sed 遇特殊字符就崩。 文件内容里有 /&\ 这些字符时,模型需要在脑子里做正则转义——这不是 LLM 擅长的事。一个简单的文本替换,经常因为转义问题失败两三轮。

坑三:每次 bash 调用都是不受约束的安全面。 Bash 能做任何事,也意味着它能做任何危险的事。模型可以读 /etc/passwd,可以写 ~/.ssh/authorized_keys,可以 curl 一个恶意脚本然后执行。你的防线只有一个字符串黑名单。

专用工具解决这三个问题:read_file 可以精确控制截断并告知模型;edit_file 用精确字符串匹配替代 sed 正则;路径沙箱 safe_path() 让工具只能访问工作目录。

关键洞察:加工具不需要改循环。

Bash万能但三个坑

核心架构:dispatch map

+--------+      +-------+      +------------------+
|  User  | ---> |  LLM  | ---> | Tool Dispatch    |
| prompt |      |       |      | {                |
+--------+      +---+---+      |   bash: run_bash |
                    ^           |   read: run_read |
                    |           |   write: run_wr  |
                    +-----------+   edit: run_edit |
                    tool_result | }                |
                                +------------------+

对比第 1 课的架构图,唯一变化是右边:从一个固定的 run_bash 函数,变成了一个字典查找。 模型返回工具名,字典返回对应的处理函数。

这个模式叫 dispatch map。它的威力在于:无论你有 4 个工具还是 40 个,循环里的代码都是同一行:

handler = TOOL_HANDLERS.get(block.name)

四步拆解:从 Bash-only 到多工具 Agent

第一步:路径沙箱 safe_path()

所有文件操作工具的第一道防线:

WORKDIR = Path.cwd()

def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

三行代码,做了一件事:确保所有路径操作都在工作目录内。

模型传来 ../../etc/passwdresolve() 会把它解析为绝对路径,然后 is_relative_to 检查发现它不在 WORKDIR 下,直接拒绝。

这比 Bash 的字符串黑名单强得多。黑名单是 "列举你不能做什么",沙箱是 "定义你只能在哪做"。前者永远有漏网之鱼,后者天然封闭。

第二步:四个工具处理函数

每个工具一个函数,各司其职:

def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"
    try:
        r = subprocess.run(command, shell=True, cwd=WORKDIR,
                           capture_output=True, text=True, timeout=120)
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)"

def run_read(path: str, limit: int = None) -> str:
    try:
        text = safe_path(path).read_text()
        lines = text.splitlines()
        if limit and limit < len(lines):
            lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
        return "\n".join(lines)[:50000]
    except Exception as e:
        return f"Error: {e}"

def run_write(path: str, content: str) -> str:
    try:
        fp = safe_path(path)
        fp.parent.mkdir(parents=True, exist_ok=True)
        fp.write_text(content)
        return f"Wrote {len(content)} bytes to {path}"
    except Exception as e:
        return f"Error: {e}"

def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        fp = safe_path(path)
        content = fp.read_text()
        if old_text not in content:
            return f"Error: Text not found in {path}"
        fp.write_text(content.replace(old_text, new_text, 1))
        return f"Edited {path}"
    except Exception as e:
        return f"Error: {e}"

注意几个设计选择:

  • run_readlimit 参数:模型可以只读前 N 行,避免大文件撑爆上下文。截断时主动告知还有多少行没显示。
  • run_write 自动创建父目录mkdir(parents=True) 让模型不需要先 mkdir -p 再写文件。
  • run_edit 用精确字符串匹配old_text not in content 直接判断,不需要正则。替换只替换第一个匹配(replace(..., 1)),避免误伤。
  • 所有路径操作都经过 safe_path():Bash 保留了独立的黑名单防护,文件工具用沙箱防护。

第三步:dispatch map 注册

TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"],
                                        kw["new_text"]),
}

一个字典,四个条目。新增工具 = 新增一个函数 + 字典里加一行。 不需要碰循环,不需要加 if-elif,不需要改任何已有代码。

配套的工具定义(schema)告诉模型每个工具长什么样:

TOOLS = [
    {"name": "bash", "description": "Run a shell command.",
     "input_schema": {"type": "object",
                      "properties": {"command": {"type": "string"}},
                      "required": ["command"]}},
    {"name": "read_file", "description": "Read file contents.",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"},
                                     "limit": {"type": "integer"}},
                      "required": ["path"]}},
    {"name": "write_file", "description": "Write content to file.",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"},
                                     "content": {"type": "string"}},
                      "required": ["path", "content"]}},
    {"name": "edit_file", "description": "Replace exact text in file.",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"},
                                     "old_text": {"type": "string"},
                                     "new_text": {"type": "string"}},
                      "required": ["path", "old_text", "new_text"]}},
]

第四步:循环集成

循环本身和第 1 课一模一样,唯一变化是工具执行那几行:

def agent_loop(messages: list):
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return
        results = []
        for block in response.content:
            if block.type == "tool_use":
                handler = TOOL_HANDLERS.get(block.name)
                output = handler(**block.input) if handler \
                    else f"Unknown tool: {block.name}"
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})

对比第 1 课,变化只有一处:

# s01: 硬编码
output = run_bash(block.input["command"])

# s02: 字典查找
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"

两行替换一行。循环结构、退出条件、消息管理,全部不变。

dispatch map:一行代码路由所有工具

架构决策:为什么这样设计

为什么用 dict 不用 if-elif

# 不要这样写
if block.name == "bash":
    output = run_bash(block.input["command"])
elif block.name == "read_file":
    output = run_read(block.input["path"])
elif block.name == "write_file":
    output = run_write(block.input["path"], block.input["content"])
# ... 每加一个工具,循环里多两行

if-elif 的问题不是性能,是耦合。每次加工具,你要改循环代码。循环是 Agent 的核心,改核心就要重新测试核心。dispatch map 把 "有哪些工具" 和 "怎么执行循环" 解耦了。

这也是 Claude Code 真实的架构选择。看它的源码,工具注册和循环执行是两个完全独立的模块。

为什么要路径沙箱

你可能觉得 "教学代码,安全不重要"。但想一下这个场景:

你在 ~/projects/my-app/ 跑 Agent,让它 "把 config.json 里的数据库密码改成新密码"。模型决定先读文件看看当前内容,调用 read_file("../../.ssh/id_rsa")——

没有沙箱?它就读到了你的 SSH 私钥。

沙箱不是 "生产级安全",而是最低限度的隔离。即使在教学场景,也不应该让模型访问工作目录以外的任何东西。

为什么 edit 用精确匹配不用正则

Claude Code 的 Edit 工具也是精确字符串替换,不是正则。原因很简单:

  1. LLM 不擅长写正则。让模型构造正则表达式,然后用这个正则去改你的代码——这是在不擅长的事情上叠加风险。
  2. 精确匹配可预测old_text 在文件里要么存在要么不存在,没有 "部分匹配" 的歧义。
  3. 只替换第一个replace(old_text, new_text, 1) 避免批量误伤。如果文件里有多处相同文本,模型需要提供更长的上下文来精确定位。

五分钟跑起来

# 克隆仓库
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code

# 安装依赖
pip install -r requirements.txt

# 配置 API Key
cp .env.example .env
# 编辑 .env,填入你的 ANTHROPIC_API_KEY 和 MODEL_ID

# 启动第二课
python agents/s02_tool_use.py

启动后你会看到 s02 >> 提示符。以下是真实的运行记录:

任务 1:读取文件

s02 >> Read the file requirements.txt
> read_file:
anthropic>=0.25.0
python-dotenv>=1.0.0
pyyaml>=6.0
The `requirements.txt` file contains three dependencies:

1. **anthropic>=0.25.0** - The Anthropic Python SDK (for interacting with Claude API)
2. **python-dotenv>=1.0.0** - For loading environment variables from `.env` files
3. **pyyaml>=6.0** - For parsing and working with YAML files

模型直接选择了 read_file 而不是 bash → cat。给了专用工具,模型自然会用。

任务 2:创建文件

s02 >> Create a file called greet.py with a greet(name) function
> write_file:
Wrote 46 bytes to greet.py
The file `greet.py` has been created with a `greet(name)` function that takes
a name as input and returns a greeting string like `"Hello, {name}!"`.

任务 3:编辑文件

s02 >> Edit greet.py to add a docstring to the function
> edit_file:
Edited greet.py
Done! A docstring has been added to the `greet` function describing its purpose.

注意模型没有先 read_fileedit_file——它在上一轮创建文件时已经知道内容,直接精确定位并插入 docstring。Agent 有记忆,不做多余的事。

任务 4:验证修改

s02 >> Read greet.py to verify the edit worked
> read_file:
def greet(name):
    """Return a greeting message for the given name."""
    return f"Hello, {name}!"
The edit worked correctly. The `greet.py` file now contains the `greet(name)` function
with the docstring included.

对比第 1 课的行为变化:s01 里模型会用 bash → cat 读文件、bash → sed 编辑文件。现在它直接选择专用工具——更精确、更安全、更省 token。你不需要告诉它 "用 read_file 而不是 cat"。

总结:从 s01 到 s02 变了什么

组件之前 (s01)之后 (s02)
工具数量1 (仅 bash)4 (bash, read, write, edit)
工具分发硬编码 run_bash() 调用TOOL_HANDLERS 字典查找
路径安全safe_path() 沙箱
文件读取bash → cat(截断不可控)read_file(精确截断 + 行数提示)
文件编辑bash → sed(正则易崩)edit_file(精确匹配替换)
Agent loopwhile True + stop_reason不变
新增工具成本改循环代码加一个函数 + 字典加一行

核心信息:循环是稳定的,工具是可扩展的。 dispatch map 让这两件事彻底解耦。

s01到s02的变更对比

下一课预告

现在 Agent 有了多个工具,能更精确地读写文件。但它仍然是 "走到哪算哪"——没有计划,不知道自己完成了多少,容易在复杂任务中迷失方向。

第 3 课:TodoWrite —— 给 Agent 一个规划系统。核心是一个 TodoManager,让 Agent 先列步骤再动手。循环里加一个 nag reminder,催促 Agent 按计划推进。

# 预告:s03 的 TodoManager
class TodoManager:
    def add(self, task: str, priority: str) -> str: ...
    def complete(self, task_id: str) -> str: ...
    def get_summary(self) -> str: ...  # "3/7 done"

没有计划的 Agent 走哪算哪。有了 TodoWrite,完成率直接翻倍。


这是《12课拆解Claude Code架构:从零掌握Agent Harness工程》系列的第 2 课。关注Claw开发者,不错过后续更新。

完整代码和交互式学习平台:github.com/shareAI-lab…

如果这篇文章对你有帮助,欢迎转发给你的技术团队。

系列目录

  • 第1课:用20行Python造出你的第一个AI Agent
  • 第2课:给Agent加工具 —— dispatch map模式详解(本文)
  • 第3课:TodoWrite —— 让Agent先想后做:规划系统
  • 第4课:Subagent —— 拆解大任务,上下文隔离
  • 第5课:按需加载领域知识——Skill机制
  • 第6课:无限对话——上下文压缩三层策略
  • 第7课:任务持久化——文件级DAG任务图
  • 第8课:后台执行——异步任务与通知队列
  • 第9课:Agent Teams——多Agent协作:团队与邮箱系统
  • 第10课:团队协议——状态机驱动的协商
  • 第11课:自治Agent——自组织任务认领
  • 第12课:终极隔离——Worktree并行执行

📌 本文原始链接第2课:给Agent加工具 —— dispatch map模式详解

🔗 更多 AI Agent 开发实战教程,访问 HuanCode

💻 完整代码仓库:github.com/shareAI-lab…