learn-claude-code 工具使用篇:Tool Dispatch 让“加一个工具 = 加一个 handler”
在 learn-claude-code(一个“Bash is all you need”理念下的迷你 Claude Code 风格 agent harness 教学仓库)中,s02 Tool Use 一课回答了 agent 演进路线上的关键一步:如何让模型从只会 bash 扩展到拥有多个专用工具,而不改动 agent 主循环一行代码。读完本文,你将理解 dispatch map(分派映射)的设计动机、safe_path 路径沙箱的实现原理、每个工具 handler 的参数与安全边界,并能直接在仓库中运行 s02 可执行代码、验证“注册即扩展”的工具接入模式。
问题起点:只有 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_file、write_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
其机制值得逐行拆解:
(WORKDIR / p):把模型给出的路径(可能是相对路径,也可能是带../的路径)拼接到工作目录WORKDIR = Path.cwd()下(见 agents/s02_tool_use.py);.resolve():将路径解析为绝对规范路径,同时展开..和符号链接,这是拦截../../etc/passwd这类逃逸的关键;is_relative_to(WORKDIR):校验解析结果仍在工作区内,否则抛出ValueError: Path escapes workspace: {p}。
注意沙箱的覆盖范围:从源码结构看,safe_path 只作用于 read_file、write_file、edit_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 /、sudo、shutdown、reboot、> /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_schema 中 path 为必填、limit 为可选(见 agents/s02_tool_use.py 的 TOOLS 定义),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.py 与 agents/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,
})
三个工程细节:
TOOL_HANDLERS.get(block.name)带兜底:模型若返回一个未注册的工具名,harness 不会崩溃,而是把Unknown tool: xxx作为工具结果回注给模型,让其自我纠正;tool_use_id与block.id一一对应:每个tool_result必须带上对应tool_use块的 id,这是 Anthropic Messages API 的配对协议要求,保证多轮工具调用时结果能对齐到正确的调用;- 一次响应可含多个 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 属于此轨道),根目录 s01–s17 是当前 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_glob(s02_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),每个条目含 name、description 和 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:运行与验证
运行环境需要 anthropic 与 python-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 文档推荐的四条指令验证各工具:
Read the file requirements.txt— 验证read_file与沙箱;Create a file called greet.py with a greet(name) function— 验证write_file(含自动建目录);Edit greet.py to add a docstring to the function— 验证edit_file的精确替换;Read greet.py to verify the edit worked— 验证“写后读回”闭环。
观察重点:哪些任务模型只调用一个工具,哪些会一次性返回多个 tool_use 块;多个调用是否按原始顺序执行、tool_result 是否按 id 正确配对回传。
小结与下一步
s02 确立了 harness 工具层的两条基本法则:
- 工具注册与循环解耦:dispatch map 让扩展成为纯增量操作,这是后续 s03–s17 各机制(权限、hooks、子代理、MCP 外部工具路由)都能“挂在同一循环上”的前提;
- 安全约束下沉到工具层:
safe_path在文件工具内强制工作区边界,工具不抛异常而是把错误字符串回注给模型,使主循环永远健壮。
文档同时留下了明确的未决问题:文件工具有沙箱,bash 没有——rm -rf / 级的操作仍依赖命令黑名单。下一步请进入 s03 Permission:在工具执行前加一道门——这个操作安全吗?需要用户批准吗?
关联阅读:docs/en/s01-the-agent-loop.md(前一课:主循环)、s02_tool_use/README.md(当前轨道同主题讲义,含
glob工具与多工具调用示例)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00