首页
/ learn-claude-code s02 Tool Use 工具分发:用一行查表映射扩展模型能力

learn-claude-code s02 Tool Use 工具分发:用一行查表映射扩展模型能力

2026-09-05 16:42:41作者:伍希望

本文基于 learn-claude-code 课程的 s02 章节(docs/ja/s02-tool-use.md)撰写。核心主题只有一个:在 s01 的 Agent 循环不改动一行代码的前提下,用「工具定义(TOOLS)+ 处理函数注册(TOOL_HANDLERS 查表分发)」的机制,把 Agent 从只有一个 bash 工具扩展到多个专用文件工具。读完本篇,你将掌握 Agent 工具层的注册/分发模式、safe_path 工作区沙箱的实现原理,以及多工具并发调用的执行顺序,能独立为自己的 Agent 添加新工具。

Tool Dispatch

问题:只有 bash 一个工具的 Agent 有多脆弱

s01 阶段构建的 Agent(见 agents/s01_agent_loop.py)只有一个 bash 工具:读文件要靠 cat,写文件要靠 echo "..." > file.py,改文件要靠 sed。这个设计有三个根本缺陷:

  1. 多一层无谓的翻译。模型的意图是「读这个文件」,却必须拼出 cat path/to/file 这样的 shell 命令。每个意图到命令的转换都浪费 token,还容易拼错(cat 的输出不可控地截断,sed 遇到特殊字符直接失败)。
  2. 不可控的错误路径。shell 命令的错误信息、退出码、截断行为对模型来说都是噪声,模型难以稳定地从输出中恢复正确信息。
  3. 安全面完全敞口。每一次 bash 调用都是不受约束的操作——rm -rfsudo 等全靠模型自觉。

read_filewrite_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.pyagent_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_use block 的 id,保证多工具调用场景下结果与调用一一对应。

结论与原文档一致:加一个工具 = 在 TOOLS 数组加一条 schema + 在 TOOL_HANDLERS 字典加一行映射,循环永远不需要改

源码纵深:两个可运行的 s02 版本

仓库里有两份 s02 实现,分别对应 12 课旧版课程线和 17 课新版课程线,对照阅读能看清这个机制的演进:

旧版 12 课线:agents/s02_tool_use.py

四个工具:bashread_filewrite_fileedit_file。各 handler 的关键实现约束:

handler 关键实现 防御点
run_bash subprocess.run(shell=True, cwd=WORKDIR, timeout=120) 危险词黑名单拦截(rm -rf /sudo 等);120 秒超时;输出截断 50000 字符
run_read safe_pathread_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 消息(contenttool_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.txtanthropic>=0.25.0python-dotenv>=1.0.0pyyaml>=6.0),并需配置含 ANTHROPIC_API_KEYMODEL_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 退出):

  1. Read the file requirements.txt —— 观察模型用 read_file 而非 cat 读文件;
  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 的精确文本替换,以及 old_text 未命中时的错误回传;
  4. 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 机制,都是在这张分发图之上继续加层,循环本体始终不变。

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