首页
/ learn-claude-code 工具使用篇:Tool Dispatch 让“加一个工具 = 加一个 handler”

learn-claude-code 工具使用篇:Tool Dispatch 让“加一个工具 = 加一个 handler”

2026-09-06 11:36:28作者:田桥桑Industrious

在 learn-claude-code(一个“Bash is all you need”理念下的迷你 Claude Code 风格 agent harness 教学仓库)中,s02 Tool Use 一课回答了 agent 演进路线上的关键一步:如何让模型从只会 bash 扩展到拥有多个专用工具,而不改动 agent 主循环一行代码。读完本文,你将理解 dispatch map(分派映射)的设计动机、safe_path 路径沙箱的实现原理、每个工具 handler 的参数与安全边界,并能直接在仓库中运行 s02 可执行代码、验证“注册即扩展”的工具接入模式。

Tool Dispatch 工具分派架构:LLM 返回 tool_use 块,由 TOOL_HANDLERS 字典查表分发到对应 handler,结果以 tool_result 回注消息流

问题起点:只有 bash 的 agent 有多脆弱

s02 文档开篇(docs/en/s02-tool-use.md)指出的核心矛盾是:

  • cat 对大文件会不可预测地截断输出;
  • sed 遇到特殊字符就容易失败;
  • 每一次 bash 调用都是一个无约束的安全暴露面(unconstrained security surface)。

而 s01 的 agent 只有一个工具 bash(可对照 agents/s01_agent_loop.py 中仅含一个 bash 条目的 TOOLS 数组)。模型想“读文件”时必须自行拼出 cat path/to/file——多一层人肉翻译,既浪费 token 又容易出错。

解法是引入 read_filewrite_file 等专用工具,并把路径沙箱下沉到工具层强制执行。文档给出的关键洞察是:添加工具不需要改循环("The loop stays the same; new tools register into the dispatch map")。

核心设计:dispatch map 取代 if/elif 链

s02 的解决方案用一个字典完成工具路由:

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

The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.

在实现文件 agents/s02_tool_use.py 中,分派映射位于第 96–101 行:

# -- The dispatch map: {tool_name: handler} --
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"]),
}

这里用 lambda **kw 统一了各 handler 的参数签名:模型返回的 block.input 是任意键值对,lambda 负责按键名取出所需参数。分派映射的本质是把“工具名 → 执行函数”的对应关系从控制流(if/elif 链)提升为数据——扩展工具时只增字典条目,不碰主循环。

路径沙箱:safe_path 防止工作区逃逸

专用文件工具的第一层防线是 safe_path,实现见 agents/s02_tool_use.py

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

其机制值得逐行拆解:

  1. (WORKDIR / p):把模型给出的路径(可能是相对路径,也可能是带 ../ 的路径)拼接到工作目录 WORKDIR = Path.cwd() 下(见 agents/s02_tool_use.py);
  2. .resolve():将路径解析为绝对规范路径,同时展开 .. 和符号链接,这是拦截 ../../etc/passwd 这类逃逸的关键;
  3. is_relative_to(WORKDIR):校验解析结果仍在工作区内,否则抛出 ValueError: Path escapes workspace: {p}

注意沙箱的覆盖范围:从源码结构看,safe_path 只作用于 read_filewrite_fileedit_file 三个文件工具,而 bash 工具不受其约束——这正是 s02 文档结尾“Changes from s01”之后遗留的安全缺口,也是 s03 Permission 一课的引子。

四个工具 handler 的实现细节

run_bash:从 s01 原样继承

agents/s02_tool_use.py 中的 run_bash 与 s01 完全一致:

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,
                           encoding="utf-8", errors="replace", timeout=120)
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)"

约束包括:危险命令黑名单(子串匹配 rm -rf /sudoshutdownreboot> /dev/)、120 秒超时、输出合并 stdout+stderr 后截断到 50000 字符。这个“把错误当作字符串返回给模型”的模式贯穿所有 handler——工具不抛异常打断循环,而是返回 Error: ... 让模型自行纠错,这是 agent harness 中典型的容错设计。

run_read:行数限制 + 字符截断

def run_read(path: str, limit: int = None) -> str:
    text = safe_path(path).read_text()
    lines = text.splitlines()
    if limit and limit < len(lines):
        lines = lines[:limit]
    return "\n".join(lines)[:50000]
  • limit(可选,integer):仅保留前 N 行,用于读取大文件时控制上下文体积;
  • 最终结果硬截断 50000 字符,防止单次工具结果撑爆消息窗口。

input_schemapath 为必填、limit 为可选(见 agents/s02_tool_use.pyTOOLS 定义),schema 就是“告诉模型我有什么能力”的声明——工具定义与 handler 实现是刻意分离的两层。

run_write:自动建目录

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

mkdir(parents=True, exist_ok=True)write_file 可以直接写出嵌套路径(如 src/pkg/mod.py)而无需模型先用 bash 建目录,降低了工具组合的调用次数。返回值报告写入字节数,模型可据此确认结果。

run_edit:精确替换,只替换第一处

def run_edit(path: str, old_text: str, new_text: str) -> str:
    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}"

两个关键行为:

  • old_text 在文件中不存在时返回 Error: Text not found,而不是静默失败——模型会收到明确信号并重新读取文件核对内容;
  • replace(old_text, new_text, 1) 的第三个参数 1 保证只替换第一次出现,避免多处相同文本被一次性误改,这是与“全局替换”类编辑工具的重要区别。

主循环:唯一的变化是一次字典查表

s02 文档强调“loop body itself is unchanged from s01”。对比 agents/s01_agent_loop.pyagents/s02_tool_use.py,s01 中硬编码的执行行:

output = run_bash(block.input["command"])

在 s02 中变为按名字查表:

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,
        })

三个工程细节:

  1. TOOL_HANDLERS.get(block.name) 带兜底:模型若返回一个未注册的工具名,harness 不会崩溃,而是把 Unknown tool: xxx 作为工具结果回注给模型,让其自我纠正;
  2. tool_use_idblock.id 一一对应:每个 tool_result 必须带上对应 tool_use 块的 id,这是 Anthropic Messages API 的配对协议要求,保证多轮工具调用时结果能对齐到正确的调用;
  3. 一次响应可含多个 tool_use 块:循环按 response.content 的原始顺序逐个执行并收集全部结果,最后以 {"role": "user", "content": results} 一次性回注——即“模型批量发令,harness 顺序执行,结果打包回传”。

版本对照:legacy 12 课版与当前 17 课版

需要说明适用前提:根据 README.md 的 Version Status,本仓库同时存在两条教程轨道——docs/ + agents/ 是旧版 12 课过渡轨道(本文关联文档 docs/en/s02-tool-use.md 属于此轨道),根目录 s01s17 是当前 17 课轨道。旧 s02 与新 s02 主题一致(Tool Use),可直接对应阅读,但实现有小幅演进:

维度 旧版 agents/s02_tool_use.py 新版 s02_tool_use/code.py
工具数量 4(bash, read_file, write_file, edit_file) 5(新增 glob
分派映射 lambda 包装:"bash": lambda **kw: run_bash(kw["command"]) 直接函数引用:"bash": run_bash
run_read 截断 50000 字符 limit 行截取并附 ... (N more lines) 提示
路径沙箱 safe_path(read/write/edit) safe_path 同样只覆盖文件工具;glob 额外做了一次结果级 is_relative_to 过滤

新版 s02_tool_use/code.py 的分派映射:

TOOL_HANDLERS = {
    "bash": run_bash, "read_file": run_read, "write_file": run_write,
    "edit_file": run_edit, "glob": run_glob,
}

从源码结构看,新版把 lambda 层去掉后,要求 handler 函数签名与模型输入键直接匹配(如 run_read(path, limit=None) 对应 {"path": ..., "limit": ...}),代码更短;lambda 版本则允许 handler 签名与工具 schema 解耦。两种写法本质相同:工具名 → 可调用对象的一次查表

新增的 run_globs02_tool_use/code.py)展示了沙箱思路的延伸:g.glob(pattern, root_dir=WORKDIR) 以工作目录为根做匹配,并对每个结果再解析一次路径做 is_relative_to 校验,双重拦截 glob 模式(如 ../*.py)逃逸:

def run_glob(pattern: str) -> str:
    import glob as g
    results = []
    for match in g.glob(pattern, root_dir=WORKDIR):
        if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
            results.append(match)
    return "\n".join(results) if results else "(no matches)"

工具定义层 TOOLS 数组是模型侧的“能力说明书”(s02_tool_use/code.py),每个条目含 namedescription 和 JSON input_schema。description 的措辞会直接影响模型选择哪个工具,例如 edit_file"Replace exact text in file once." 中的 “once” 就把“只替换第一处”的语义前移到 schema 层。

What Changed From s01

s02 文档给出的前后对照表(以旧版 12 课轨道为准):

Component Before (s01) After (s02)
Tools 1 (bash only) 4 (bash, read, write, edit)
Dispatch Hardcoded bash call TOOL_HANDLERS dict
Path safety None safe_path() sandbox
Agent loop Unchanged Unchanged

一句话总结扩展成本:加一个工具 = TOOLS 数组加一条 schema 定义 + TOOL_HANDLERS 字典加一行映射while True 主循环一行不动。

Try It:运行与验证

运行环境需要 anthropicpython-dotenv(见 requirements.txt),并配置环境变量:.env 中设置 MODEL_ID(必需,agents/s02_tool_use.py 在启动时通过 os.environ["MODEL_ID"] 强读取)、API Key,可选 ANTHROPIC_BASE_URL 指向兼容端点(设置该变量时代码会自动移除 ANTHROPIC_AUTH_TOKEN,见 agents/s02_tool_use.py)。

cd learn-claude-code
python agents/s02_tool_use.py      # 旧版 12 课轨道(对应本文档)
python s02_tool_use/code.py         # 当前 17 课轨道的同主题实现

进入交互式 s02 >> 提示符后,用 s02 文档推荐的四条指令验证各工具:

  1. Read the file requirements.txt — 验证 read_file 与沙箱;
  2. Create a file called greet.py with a greet(name) function — 验证 write_file(含自动建目录);
  3. Edit greet.py to add a docstring to the function — 验证 edit_file 的精确替换;
  4. Read greet.py to verify the edit worked — 验证“写后读回”闭环。

观察重点:哪些任务模型只调用一个工具,哪些会一次性返回多个 tool_use 块;多个调用是否按原始顺序执行、tool_result 是否按 id 正确配对回传。

小结与下一步

s02 确立了 harness 工具层的两条基本法则:

  1. 工具注册与循环解耦:dispatch map 让扩展成为纯增量操作,这是后续 s03–s17 各机制(权限、hooks、子代理、MCP 外部工具路由)都能“挂在同一循环上”的前提;
  2. 安全约束下沉到工具层safe_path 在文件工具内强制工作区边界,工具不抛异常而是把错误字符串回注给模型,使主循环永远健壮。

文档同时留下了明确的未决问题:文件工具有沙箱,bash 没有——rm -rf / 级的操作仍依赖命令黑名单。下一步请进入 s03 Permission:在工具执行前加一道门——这个操作安全吗?需要用户批准吗?

关联阅读:docs/en/s01-the-agent-loop.md(前一课:主循环)、s02_tool_use/README.md(当前轨道同主题讲义,含 glob 工具与多工具调用示例)。

登录后查看全文
热门项目推荐
相关项目推荐