learn-claude-code s02 Tool Use 工具分发:用一行查表映射扩展模型能力
本文基于 learn-claude-code 课程的 s02 章节(docs/ja/s02-tool-use.md)撰写。核心主题只有一个:在 s01 的 Agent 循环不改动一行代码的前提下,用「工具定义(TOOLS)+ 处理函数注册(TOOL_HANDLERS 查表分发)」的机制,把 Agent 从只有一个 bash 工具扩展到多个专用文件工具。读完本篇,你将掌握 Agent 工具层的注册/分发模式、safe_path 工作区沙箱的实现原理,以及多工具并发调用的执行顺序,能独立为自己的 Agent 添加新工具。
问题:只有 bash 一个工具的 Agent 有多脆弱
s01 阶段构建的 Agent(见 agents/s01_agent_loop.py)只有一个 bash 工具:读文件要靠 cat,写文件要靠 echo "..." > file.py,改文件要靠 sed。这个设计有三个根本缺陷:
- 多一层无谓的翻译。模型的意图是「读这个文件」,却必须拼出
cat path/to/file这样的 shell 命令。每个意图到命令的转换都浪费 token,还容易拼错(cat的输出不可控地截断,sed遇到特殊字符直接失败)。 - 不可控的错误路径。shell 命令的错误信息、退出码、截断行为对模型来说都是噪声,模型难以稳定地从输出中恢复正确信息。
- 安全面完全敞口。每一次 bash 调用都是不受约束的操作——
rm -rf、sudo等全靠模型自觉。
而 read_file、write_file 这类专用工具,允许在工具层面强制路径沙箱(path sandboxing),把「文件不能越出工作区」变成一条不可绕过的规则。
s02 章节的核心洞察(原文引用):
"加一个工具,只需加一个 handler"——循环保持不变;新工具只需要注册进 dispatch map(分发映射表)。
总体架构:查表分发取代硬编码调用
s02 给出的解法是一张「工具分发图」。用户 prompt 进入 LLM,LLM 返回 tool_use block,Harness 侧不再硬编码执行某一个函数,而是查一个 {tool_name: handler_function} 字典:
+--------+ +-------+ +------------------+
| 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.
对照 s01 的实现就能看出「循环不变」的含义。agents/s01_agent_loop.py 中,工具执行是硬编码的一行:
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"]) # 硬编码:只会执行 bash
s02 只把这一行替换成了查表调用,while True 循环、stop_reason 判断、消息追加全部原样保留。
机制拆解:从沙箱到查表调用的三步
第一步:每个工具有自己的 handler,路径必须过沙箱
所有文件类工具在执行前都先经过 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
要点在于先 resolve()(把相对路径、..、符号链接全部展开为真实绝对路径),再用 is_relative_to(WORKDIR) 判定是否仍在工作区内。校验失败直接抛 ValueError,被 handler 的 except 捕获后以 Error: Path escapes workspace: ... 的形式回传给模型,让模型自行纠正,而不是让异常炸掉整个循环。
以 run_read 为例(agents/s02_tool_use.py):
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}"
两个工程细节值得注意:limit 参数让模型可以只读文件前 N 行(超长文件不必一次性塞进上下文);输出统一截断到 50000 字符以内,避免单次工具结果撑爆上下文。
第二步:TOOL_HANDLERS 字典完成「名称 → 函数」的绑定
agents/s02_tool_use.py 中注册了四个工具:
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 而不是直接存函数引用,是因为模型返回的 block.input 是一个 dict,lambda 起到「按 schema 里的参数名解包」的适配作用——schema 里写 limit,handler 里就用 kw.get("limit") 取。字典查表天然替代了任何 if/elif 分支链,这就是文档强调的 "One lookup replaces any if/elif chain"。
与 handler 配套的是 TOOLS 数组(agents/s02_tool_use.py),它把每个工具的能力用 JSON 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"]}},
]
name 是分发时的键,必须与 TOOL_HANDLERS 的键一一对应;description 决定模型何时选用该工具;input_schema 约束参数结构。
第三步:循环内按名查表,未知名优雅降级
agents/s02_tool_use.py 的 agent_loop 与 s01 相比,改动只集中在查表这一行:
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})
两处防御性设计值得学习:
- 未注册工具不抛异常:
TOOL_HANDLERS.get(block.name)查不到时返回"Unknown tool: xxx"字符串,作为正常tool_result回传,模型看到后会自行改道,而不是让整个循环崩溃; tool_use_id精确配对:每个tool_result都携带对应tool_useblock 的id,保证多工具调用场景下结果与调用一一对应。
结论与原文档一致:加一个工具 = 在 TOOLS 数组加一条 schema + 在 TOOL_HANDLERS 字典加一行映射,循环永远不需要改。
源码纵深:两个可运行的 s02 版本
仓库里有两份 s02 实现,分别对应 12 课旧版课程线和 17 课新版课程线,对照阅读能看清这个机制的演进:
旧版 12 课线:agents/s02_tool_use.py
四个工具:bash、read_file、write_file、edit_file。各 handler 的关键实现约束:
| handler | 关键实现 | 防御点 |
|---|---|---|
| run_bash | subprocess.run(shell=True, cwd=WORKDIR, timeout=120) |
危险词黑名单拦截(rm -rf /、sudo 等);120 秒超时;输出截断 50000 字符 |
| run_read | 先 safe_path 再 read_text |
工作区沙箱 + limit 限行 + 50000 字符截断 |
| run_write | fp.parent.mkdir(parents=True, exist_ok=True) |
自动创建父目录;沙箱校验 |
| run_edit | content.replace(old_text, new_text, 1) |
old_text 未命中时返回明确错误;只替换第一处,避免误伤多处同名文本 |
所有 handler 都遵循同一错误契约:不抛异常,返回 Error: ... 字符串。这个契约让「失败的工具调用」和「成功的工具调用」在消息流里是同构的,模型可以把错误信息当作观察结果继续推理。
新版 17 课线:s02_tool_use/code.py
在旧版基础上扩展到五个工具(新增 glob),并且 TOOL_HANDLERS 简化为直接函数引用(s02_tool_use/code.py):
TOOLS = [
{"name": "bash", "description": "Run a shell command.", ...},
{"name": "read_file", "description": "Read file contents.", ...},
{"name": "write_file", "description": "Write content to a file.", ...},
{"name": "edit_file", "description": "Replace exact text in a file once.", ...},
{"name": "glob", "description": "Find files matching a glob pattern.", ...},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob,
}
glob 工具的沙箱处理更有意思(s02_tool_use/code.py):glob.glob(pattern, root_dir=WORKDIR) 匹配后,再对每个结果做一遍 resolve().is_relative_to(WORKDIR) 校验,防止符号链接把结果指向工作区之外——沙箱校验从「入口一次」变成「入口 + 每条结果」。
代码里的注释直接点明了 s01 与 s02 的对应关系(s02_tool_use/code.py):
# s01: output = run_bash(block.input["command"])
# s02: output = TOOL_HANDLERSblock.name
更完整的章节叙事(含多工具调用观察要点、速查表、与 s03 Permission 的衔接)可参阅三语 README:English · 中文 · 日本語。
多个工具调用:按原始顺序逐个执行
模型经常一次性返回多个 tool_use block——比如「读一下 a.py 和 b.py,顺便列出所有 .py 文件」。s02 的循环对这种情况的处理是:遍历 response.content,按模型返回的原始顺序逐个执行,把所有结果打包成一条 user 消息(content 为 tool_result 列表)统一回传。由于每个 tool_result 都携带 tool_use_id,多调用的结果不会串行错乱。这也决定了工具天然是串行执行的——如果需要并行执行工具,属于后续课程(如任务系统)才引入的能力。
相对 s01 的变更总览
| 组件 | 之前 (s01) | 之后 (s02) |
|---|---|---|
| 工具数量 | 1(仅 bash) | 4(bash, read, write, edit);新版课程线再加 glob 共 5 个 |
| 工具执行 | 硬编码 run_bash() 调用 |
TOOL_HANDLERS 字典查表分发 |
| 路径安全 | 无 | safe_path() 工作区沙箱(仅文件类工具) |
| Agent 循环 | while True + stop_reason 判断 |
与 s01 完全一致,一行未改 |
一个需要记住的边界:safe_path 只保护文件类工具,bash 依旧不受路径限制(危险词黑名单只是粗粒度拦截)。这正是课程下一章 s03 Permission 的出发点——在工具执行前加一道权限闸门。
动手运行
前置依赖见 requirements.txt(anthropic>=0.25.0、python-dotenv>=1.0.0、pyyaml>=6.0),并需配置含 ANTHROPIC_API_KEY 与 MODEL_ID 的 .env 文件(仓库提供 .env.example 模板)。
运行旧版 12 课线实现(即本文档 docs/ja/s02-tool-use.md 指定的入口):
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # 配置 ANTHROPIC_API_KEY 与 MODEL_ID
python agents/s02_tool_use.py
运行新版 17 课线实现(多一个 glob 工具):
python s02_tool_use/code.py
按原文档给出的四步验证流测试(交互式提示符 s02 >>,输入 q 退出):
Read the file requirements.txt—— 观察模型用read_file而非cat读文件;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的精确文本替换,以及old_text未命中时的错误回传;Read greet.py to verify the edit worked—— 验证读取结果确认编辑生效。
进阶观察点:输入 Read both README.md and requirements.txt, then create a summary file,注意模型是否会一次返回多个 tool_use,以及它们的执行顺序与 tool_result 配对是否正确。
小结
s02 章节的全部技术含量浓缩为一句话:工具扩展 = schema 注册 + handler 查表分发,循环零改动。safe_path 沙箱示范了「安全约束下沉到工具层」的做法,Error: ... 字符串契约示范了「失败也是合法观察结果」的 Agent 设计哲学,tool_use_id 配对示范了多工具调用的正确性保证。掌握这套模式后,给自己的 Agent 增加新能力(搜索、数据库、网络请求)都归结为同一件事:写一个 handler,加一行映射。而权限控制(s03)、hooks、任务系统等更复杂的 harness 机制,都是在这张分发图之上继续加层,循环本体始终不变。
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 StartedRust0623
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