首页
/ learn-claude-code s02 深度解析:工具分发机制——加一个工具,只加一个 handler

learn-claude-code s02 深度解析:工具分发机制——加一个工具,只加一个 handler

2026-09-06 18:35:57作者:姚月梅Lane

本文基于 learn-claude-code 仓库的课程文档 docs/zh/s02-tool-use.md 与配套源码 agents/s02_tool_use.pys02_tool_use/code.py 展开,讲解 Agent Harness 层中最核心的「工具分发(Tool Dispatch)」设计:如何在完全不改动 s01 Agent 循环的前提下,把单一 bash 工具扩展为多个带路径沙箱的专用文件工具。读完后,你将能够理解 TOOLS 定义、TOOL_HANDLERS 查表分发、safe_path 工作区沙箱三者的协作关系,并掌握「加一个工具 = 加一条 schema + 加一个 handler」的扩展方法。

Tool Dispatch 工具分发示意图

一、问题:只有一个 bash 工具不够用

在 s01(Agent Loop)中,Agent 只有 bash 一个工具:读文件要 cat,写文件要 echo "..." > file.py,改文件要 sed。课程文档 s02_tool_use/README.zh.md 指出了这套做法的三个具体痛点:

  1. 翻译损耗:模型想的是"读这个文件",却必须拼出 cat path/to/file 这样的 shell 命令,多了一层翻译,浪费 token,还容易拼错;
  2. 行为不可预测cat 的截断行为不可预测,sed 遇到特殊字符就会崩;
  3. 安全面过大:每一次 bash 调用都是一个不受约束的安全面。

因此 s02 引入专用工具(read_filewrite_file 等),目的是在工具层面做路径沙箱——文件读写被强制限制在工作区内,而不是依赖 shell 命令"自觉"。

文档给出了本节的关键洞察:

加工具不需要改循环。 新工具注册进 dispatch map 就行,循环体一字不动。

这一点可以在源码中得到验证:对比 agents/s01_agent_loop.pyagents/s02_tool_use.py,两者 agent_loop 的差异只有一行——

# 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}"

二、解决方案:工具分发架构

文档给出的整体架构如下(User prompt → LLM → Tool Dispatch,tool_result 回灌模型):

+--------+      +-------+      +------------------+
|  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.

核心思想只有一句话:dispatch map 就是一个 {tool_name: handler_function} 的字典,一次查表替代任何 if/elif 链。工具名由模型在 tool_use block 中给出,循环按名称查表调用对应 handler,把输出包装成 tool_result 回传。

三、工作原理:从沙箱到分发的三层拆解

3.1 第一层:safe_path() 路径沙箱

文档「工作原理」第 1 步指出:每个工具有一个处理函数,路径沙箱防止逃逸工作区。对应 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() 即运行命令时所在的目录;
  • .resolve():解析 ..、符号链接等,得到真实绝对路径——这一步是关键,没有它 ../ 可以直接绕过前缀检查;
  • is_relative_to(WORKDIR):解析后仍必须位于工作区内,否则抛出 ValueError("Path escapes workspace: ...")

注意抛出的异常会被各 handler 的 try/except 捕获并转成 Error: Path escapes workspace: ... 这样的字符串返回给模型(见 agents/s02_tool_use.pyrun_read 的写法),模型读到错误信息后会自行换路径重试——沙箱不是"拦截后崩溃",而是把越界变成一个可读的反馈。

文档在「接下来」一节明确提示了这套沙箱的边界:safe_path 只保护文件类工具,bash 本身不受限制rm -rf / 仍会被 shell 执行,仅被关键词黑名单挡下),这正是 s03 Permission 要解决的问题。

3.2 第二层:各工具的处理函数

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]

结合 agents/s02_tool_use.pys02_tool_use/code.py 两个版本的源码,s02 的工具实现细节如下:

工具 实现函数 关键行为(源码确认)
bash run_bash(command) 关键词黑名单 ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] 命中即返回 Error: Dangerous command blockedsubprocess.run(shell=True, cwd=WORKDIR, timeout=120),超 120 秒返回 Error: Timeout (120s);输出截断至 50000 字符,无输出时返回 (no output)
read_file run_read(path, limit) safe_path 校验;limit 截取前 N 行;结果截断至 50000 字符;s02_tool_use/code.py 版本还会追加 ... (N more lines) 提示剩余行数
write_file run_write(path, content) safe_path 校验;parent.mkdir(parents=True, exist_ok=True) 自动创建父目录;返回 Wrote N bytes to path
edit_file run_edit(path, old_text, new_text) safe_path 校验;old_text not in text 时返回 Error: text not foundreplace(old_text, new_text, 1) 保证只替换第一处,与 schema 描述 "Replace exact text in file once" 一致
glob(章节扩展版) run_glob(pattern) s02_tool_use/code.pyg.glob(pattern, root_dir=WORKDIR),且对每个匹配结果再次做 is_relative_to(WORKDIR) 过滤,防止符号链接逃出工作区;无匹配返回 (no matches)

关于工具数量:课程文档 docs/zh/s02-tool-use.mdagents/s02_tool_use.py4 个工具(bash、read_file、write_file、edit_file)讲解;而章节完整代码 s02_tool_use/code.py 额外加入了第 5 个 glob 工具。两者的循环与分发机制完全相同,glob 正是「加一个工具只加两行」这一论点的最小示例。

所有 handler 都遵循同一个错误约定:不抛异常给循环,而是把 Error: ... 作为字符串返回。这样工具失败只是一次普通的 tool_result,模型可以阅读错误并决定下一步,循环本身不会被异常打断。

3.3 第三层:TOOLS 定义与 input_schema

文档强调"加工具 = 加 handler + 加 schema"。schema 是告诉模型"我能做什么"的 JSON 定义,agents/s02_tool_use.py 中四个工具的完整定义:

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"]}},
]

各参数含义与约束:

参数 所属工具 类型 必填 说明
command bash string 要执行的 shell 命令,在 WORKDIR 下以 shell=True 运行
path read_file / write_file / edit_file string 相对 WORKDIR 的路径,会被 safe_path 沙箱校验
limit read_file integer 最多返回的行数;缺省读全文(仍受 50000 字符截断约束)
content write_file string 完整文件内容,直接覆盖写入(父目录自动创建)
old_text edit_file string 要被精确匹配替换的原文片段,匹配不到则报错
new_text edit_file string 替换文本,仅替换 old_text 的第一处出现

3.4 分发映射:TOOL_HANDLERS

文档第 2 步给出 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"]),
}

agents/s02_tool_use.py 采用 lambda 包装,作用是把模型传入的 block.input 字典(参数名各不相同)统一解包成各 handler 的位置参数;而 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,
}

两种写法等价,dict.get(name) 一次查表取代了硬编码分支。字典键必须与 TOOLS 中的 name 严格一致——TOOLS 负责让模型"知道"工具有什么,TOOL_HANDLERS 负责让循环"知道"工具怎么执行,两者构成一对。

3.5 循环中按名称查找:s01 循环零改动

文档第 3 步说明循环中按名称查找处理函数,循环体本身与 s01 完全一致:

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

放入完整上下文(agents/s02_tool_use.py),s02 的 agent_loop 是:

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) 查不到时不抛 KeyError,而是返回 Unknown tool: {name} 字符串,保证消息序列仍然合法;
  • 多个工具调用按序执行:模型一次响应可能包含多个 tool_use block(例如"读一下 a.py 和 b.py,然后列出所有 .py 文件")。for block in response.contentresponse.content 中的原始顺序逐个执行,所有结果打包成一条 user 消息(contenttool_result 列表)一次性回灌;
  • 回灌协议:每个 tool_result 通过 tool_use_id 与模型发出的调用一一配对,这是 Anthropic Messages API 的约定,agents/s01_agent_loop.py 中同样的配对逻辑在 s01 就已存在,s02 没有改变消息协议。

四、相对 s01 的变更

完整继承文档的变更对照表:

组件 之前 (s01) 之后 (s02)
Tools 1 (仅 bash) 4 (bash, read, write, edit)
Dispatch 硬编码 bash 调用 TOOL_HANDLERS 字典
路径安全 safe_path() 沙箱
Agent loop 不变 不变

s02_tool_use/README.zh.md 的速查表互相印证:

概念 一句话
TOOL_HANDLERS 工具名 → 处理函数的字典。加工具 = 加一行映射
工具定义 告诉模型"我能做什么"的 JSON schema
多工具调用 模型可一次返回多个 tool_use,并按原始顺序逐个执行
循环不变 s01 的 while True 循环一行都没改

对照两份源码可以确认"循环不变"并非修辞:agents/s01_agent_loop.pyagents/s02_tool_use.pyagent_loop 在 LLM 调用参数(max_tokens=8000)、stop_reason != "tool_use" 退出判断、assistant 消息追加、tool_result 回灌结构上逐行一致,唯一分叉点就是工具执行那一行。

五、动手运行

环境准备(依据 requirements.txt):

pip install anthropic>=0.25.0 python-dotenv>=1.0.0
# 配置 .env:必须设置 MODEL_ID,可选 ANTHROPIC_BASE_URL(指向兼容 API 的代理时,
# 程序会自动 pop 掉 ANTHROPIC_AUTH_TOKEN,见 agents/s02_tool_use.py L29-L36)

按文档「试一试」一节运行(交互模式,输入 q 退出):

cd learn-claude-code
python agents/s02_tool_use.py

也可以运行包含第 5 个 glob 工具的章节完整版:

python s02_tool_use/code.py

文档给出的测试 prompt(英文 prompt 对 LLM 效果更好,也可以用中文):

  1. Read the file requirements.txt
  2. Create a file called greet.py with a greet(name) function
  3. Edit greet.py to add a docstring to the function
  4. Read greet.py to verify the edit worked

章节 README 另外补充了针对 glob 与多工具并发的 prompt 及观察重点:

  1. Read the file README.md and tell me what this project is about
  2. Create a file called test.py that prints "hello", then read it back
  3. Find all Python files in this directory
  4. Read both README.md and requirements.txt, then create a summary file

观察重点(来自 s02_tool_use/README.zh.md):模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?终端中每次 tool_use 都会打印 > 工具名: 与输出前 200 字符(agents/s02_tool_use.py),便于核对执行顺序。

六、边界与下一步

s02 交付后的 Agent 状态与遗留问题(文档「接下来」一节的原文结论):

  • 文件类工具受 safe_path 保护,bash 不受限制——黑名单只挡 rm -rf /sudo 等关键词,绕开词面的危险命令仍能执行;
  • 工具输出统一截断在 50000 字符、bash 超时 120 秒,属于朴素但有效的护栏,尚无审批机制。

由此自然过渡到 s03 Permission:在工具执行之前加一道门——"这个操作安全吗?需要用户批准吗?"相关实现见 s03_permission/

本篇小结:s02 的全部增量可以压缩成三条——TOOLS 数组加一条 schema、TOOL_HANDLERS 字典加一行映射、文件类 handler 统一过 safe_path 沙箱。循环、消息协议、回灌机制全部复用 s01,这正是课程反复强调的 Harness 工程原则:把可变性集中在注册表上,让核心循环保持稳定。

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