s02:工具使用

已更新于 2026-06-23 08:00:00
从单一 bash 工具扩展到五个专用工具,理解 dispatch map、工具 schema、路径沙箱和消息规范化,同时掌握主循环保持不变的核心设计原则。
预计阅读:约 26 分钟 · 字数:4,627
重点看 dispatch map、schema、safe_path、消息规范化和多工具调用。能解释为什么不能所有事情都走 bash,再继续下一节。

来源与致谢

本节整理自 Learn Claude Code 官方站点 s02:工具使用 与 GitHub 源项目

shareAI-lab/learn-claude-code · s02_tool_use

,按 MIT License 授权整理;同时迁入阿衡学习笔记 s02-工具使用.md 的补充理解。本站内容为非官方学习笔记,不代表 shareAI-lab 或 Anthropic 官方立场。

学习目标

  1. 区分工具调用声明、路由和真实动作。
  2. 用 dispatch map 解释工具如何接入 Agent 循环。
  3. 说明工具 schema 为什么会影响模型选择。
  4. 解释为什么不能所有操作都交给 bash
  5. 说明 safe_path() 如何挡住路径逃逸。
  6. 判断复杂系统里为什么要在发给 API 前规范化 messages

quote:核心原则

主循环还是 s01 的 while;新工具往 dispatch map 里挂 handler,循环主逻辑框架不用动。

"加一个工具,只加一个 handler" —— 查表、执行、收结果,主循环逻辑照旧。

把 LLM 的意图路由成动作。

拆开来说:

"意图"是 LLM 输出的工具调用声明,长这样:

{ "name": "read_file", "input": { "path": "main.py" } }

这只是一段 JSON,不是函数调用,它本身什么都不会执行。

"路由"是 Agent 做的事,就是把这段 JSON 里的 name 字段查一张表:

dispatch = {
    "bash":       run_bash,
    "read_file":  run_read,
    "write_file": run_write,
    "edit_file":  run_edit,
}
handler = dispatch[tool_name]   # 查表
result  = handler(tool_input)   # 执行

handler 是真正的执行者,把文件读出来、把内容写进去、把命令跑一遍。

  • LLM 只负责说"我要用 read_file",它不知道背后是怎么实现的
  • Agent 持有 dispatch 表,不关心具体是调用第几个工具,统一走查表 → 执行 → 回传
  • 加一个新工具,只需要往表里加一行,主循环逻辑不动

只有 bash 一个工具

s01 的 Agent 只有一个 bash 工具。读文件要 cat,写文件要 echo "..." > file.py,改文件要 sed

模型想的是"读这个文件",却要拼出 cat path/to/file。多了一层翻译,浪费 token,还容易拼错。


这一章要解决什么问题

为什么要有专用工具,而不是全部用 bash 搞定

只有 bash 时,所有操作都走 shell。cat 截断不可预测,sed 遇到特殊字符就崩,每次 bash 调用都是一次无约束的安全风险敞口。

假设只有一个工具 run_bash,LLM 想读文件就叫 Agent 执行 cat main.py,想写文件就执行 echo ... > file.txt,什么都走 shell 命令。

这样做有三个问题:

  1. cat 截断不可预测

    cat 是把文件原样输出到终端,文件很大时输出会被截断,LLM 拿到的是残缺内容,它不知道自己没看完。换成 read_file 工具,你可以在 handler 里加 offset / limit 参数,精确控制读多少行。

  2.  sed 遇到特殊字符就崩

    LLM 生成的 sed 命令里一旦出现引号、斜杠、换行,shell 转义就容易出错,命令直接报错或者产生错误结果。换成 edit_file 工具,handler 里用 Python 字符串操作,不经过 shell,特殊字符不再是问题。

  3. bash 是"无边界"的

    run_bash 能执行任意命令——rm -rf /curl 把数据传出去、cd 到任何目录都行。没有任何约束。而 read_file / write_file 的 handler 里可以加路径沙箱:

    def safe_path(p):
        path = (WORKDIR / p).resolve()
        if not path.is_relative_to(WORKDIR):
            raise ValueError("不允许访问工作目录以外的路径")
        return path

    LLM 就算想读 /etc/passwd,handler 直接拒掉,bash 根本没机会执行。

    专用工具(read_file, write_file)可以在工具层面做路径沙箱。

tip:关键洞察

加工具不需要改主循环逻辑。


全局视角:工具分发

TOOL_HANDLERS[block.name](**block.input)
tool_use:{ name, input }TOOL_HANDLERS[name]bashread_filewrite_fileedit_fileglob→ tool_result → messages[]
dispatch trace
[ empty ]
The Dispatch Map
A dictionary maps tool names to handler functions. The loop code never changes.
1/6

Harness 层: 工具分发 — 扩展模型能触达的边界。

Tool Dispatch

s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:run_bash() 替换为 TOOL_HANDLERS[block.name]() 查表分发。

给 Agent 加一个工具只需要做两件事:

  1. 定义工具:在 TOOLS 数组里加一条描述
  2. 注册处理函数:在 TOOL_HANDLERS 字典里加一个映射

架构图

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

dispatch map 是一个字典:{tool_name: handler_function}。一次查找替代所有 if/elif 链。


从 1 个工具到 5 个工具

s01 只有一个 bash:

TOOLS = [{"name": "bash", ...}]
 
def run_bash(command): ...

s02 加到 5 个,每个工具都是独立定义:

TOOLS = [
    {"name": "bash",       "description": "Run a shell command.", ...},
    {"name": "read_file",  "description": "Read file contents.",  ...},
    {"name": "write_file", "description": "Write content to file.", ...},
    {"name": "edit_file",  "description": "Replace text in file once.", ...},
    {"name": "glob",       "description": "Find files by pattern.", ...},
]

每个工具有自己的实现函数:

def run_read(path, limit=None):
    lines = safe_path(path).read_text().splitlines()
    if limit:
        lines = lines[:limit]          # LLM 可以指定只看前 N 行,避免读超大文件
    return "\n".join(lines)
 
def run_write(path, content):
    safe_path(path).write_text(content)
    return f"Wrote {len(content)} bytes to {path}"
 
def run_edit(path, old_text, new_text):
    text = safe_path(path).read_text()
    if old_text not in text:
        return "Error: text not found"  # 找不到就报错,而不是静默失败
    safe_path(path).write_text(text.replace(old_text, new_text, 1))  # 只替换第一处
    return f"Edited {path}"
 
def run_glob(pattern):
    import glob as g
    # root_dir 把搜索范围锁在工作目录,LLM 看不到工作目录之外的文件
    return "\n".join(g.glob(pattern, root_dir=WORKDIR))

工具分发

# dispatch map:工具名 → 处理函数
# 加新工具只需在这里加一行,下面的循环不用动
TOOL_HANDLERS = {
    "bash":       run_bash,
    "read_file":  run_read,
    "write_file": run_write,
    "edit_file":  run_edit,
    "glob":       run_glob,
}
 
# 循环里只改了一行——从硬编码 run_bash 变成查表:
for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS[block.name]    # 按名字找到对应函数
        output = handler(**block.input)        # block.input 是 LLM 传来的参数字典
        results.append(...)

加一个工具 = 在 TOOLS 数组加一条 + 在 TOOL_HANDLERS 字典加一行。循环不变。


工具注册表解决了扩展性问题。但专用工具还有另一个核心价值——安全边界。

下面看看这些工具内部是怎么设防的。

工作原理

1. 路径沙箱防止逃逸

"沙箱"就是划一个圈,只能在圈里活动。"逃逸"就是绕出这个圈。

具体到这里:Agent 有一个工作目录,比如 /home/user/project/。正常情况下 LLM 操作文件应该只在这个目录里。

"逃逸"长什么样?

LLM 生成了这样的参数:

path = "../../etc/passwd"

如果不加检查,WORKDIR / "../../etc/passwd" 解析出来就是 /etc/passwd,Agent 真的会去读系统密码文件。这就叫"逃出了工作目录",即路径逃逸。

沙箱怎么挡住它?

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
 
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]

resolve() 是关键——它会把 ../../etc/passwd 这样的相对跳转全部展开成真实路径,骗不过去。检查完再放行,越界直接报错,bash 根本不会被调用。

2. dispatch map 将工具名映射到处理函数

# 用 lambda 把 block.input 字典拆包成各函数期望的具名参数
# kw.get("limit") 而非 kw["limit"]:limit 是可选参数,缺省时返回 None
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"]),
}

3. 循环中按名称查找处理函数

for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS.get(block.name)   # 未知工具返回 None,不抛异常
        output = handler(**block.input) if handler \
            else f"Unknown tool: {block.name}"     # 降级为报错字符串,让 LLM 感知
        results.append({
            "type": "tool_result",
            "tool_use_id": block.id,   # 必须与请求的 block.id 对应,API 用它匹配结果
            "content": output,
        })

important:核心法则

加工具 = 加 handler + 加 schema。循环永远不变。

这里的 schema 完整结构大概是:

{
  "name": "read_file",
  "description": "读取指定路径的文件内容",
  "input_schema": {
    "type": "object",
    "properties": {
      "path": { "type": "string", "description": "文件路径" },
      "limit": { "type": "integer", "description": "最多读多少行" }
    },
    "required": ["path"]
  }
}

三层东西:

  • name — 工具叫什么,LLM 调用时用这个名字指定
  • description — 这个工具是干什么的,LLM 靠这个决定"该不该用它"
  • input_schema — 调用时需要传哪些参数、每个参数是什么类型、哪些是必填

"schema"这个词本身就是"结构描述"的意思,在这里就是对工具接口的一份声明。LLM 看不到背后的实现代码,它只凭这份声明来理解"这个工具能做什么、需要哪些参数、怎么调用"。


多个工具调用

模型经常一次返回多个 tool_use:"读一下 a.py 和 b.py,然后列出所有 .py 文件"。

教学版按 response.content 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(→ 扩展:深入 CC 源码)。


速查

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

相对 s01 的变更

组件之前 (s01)之后 (s02)
工具数量1 (bash)5 (+read, write, edit, glob)
工具执行硬编码 run_bash()TOOL_HANDLERS 查表分发
路径安全safe_path 校验(仅 file tools)
循环while True + stop_reason与 s01 完全一致

消息规范化

教学版的 messages 列表直接发给 API。但当系统变复杂后(工具超时、用户取消、压缩替换),内部消息列表会出现 API 不接受的格式问题,需要在发送前做一次规范化。

为什么需要

API 协议有三条硬性约束:

  1. 每个 tool_use必须有匹配的 tool_result(通过 tool_use_id 关联)
  2. user / assistant 消息必须严格交替(不能连续两条同角色)
  3. 只接受协议定义的字段(内部元数据会导致 400 错误)

什么情况下内部列表会"坏掉"?

① 工具超时 LLM 说"我要调 run_bash",写进了 messages(assistant 消息)。但工具跑了 30 秒超时,Agent 决定放弃这次调用。于是 messages 里有一条 tool_use,但没有对应的 tool_result。API 收到这个会直接报错。

② 用户中途取消 Turn 2 还没跑完,用户按了 Ctrl+C。assistant 消息写进去了,但 tool_result 还没追加,下次发送就不合法。API 的要求是:只要 assistant 消息里出现了 tool_use,后续 user 消息里就必须有一条 tool_use_id 对得上的 tool_result。哪怕工具没跑、取消了、超时了,也得补一条"我知道这次调用失败了"的 tool_result 进去,否则 API 报错。

③ 上下文压缩 messages 太长时,Agent 会把中间一段历史压缩成一条摘要消息替换掉。但如果压缩边界切到了一对 tool_use / tool_result 中间,配对就断了。

normalize_messages(messages) → API
tool_useend_turnStartAPI Callstop_reason?Execute ToolAppend ResultBreak / Done
messages[]
user
"Read file a.py and summarize it."
assistant
tool_use { id: "tu_001", name: "read_file", input: { path: "a.py" } }
length: 2
LLM 决定调用工具
LLM 返回 tool_use 块,agent 把 assistant 消息追加进 messages。
1/4

所以"规范化"就是在每次发给 API 之前,扫一遍 messages,把这些残缺的、不配对的、顺序错乱的条目修复或裁掉,确保发出去的格式 API 一定能接受。

为什么不在写入时就格式化,而要等到发送前?

有三个原因:

  1. 写入时不知道结果

    tool_use 写进 messages 的时候,工具还没跑。超时和取消发生在执行阶段,没法在写入时就"预补"配对,因为配对还没发生。

  2. 内部 messages 有额外用途

    agent 需要看到"tool_use 有、tool_result 没有"才知道哪一步崩了,才能决定要不要重试。如果写入时就把中间状态抹掉,这些调试和重试信息就丢了。

  3. 压缩是事后操作

    压缩发生在消息已经合法写入之后,把一段历史删掉换成摘要,可能制造出新的不合法结构。没有办法在原始写入时预防这个问题。

一句话

内部 messages 是"事件日志",记录真实发生了什么;发给 API 的 messages 是"对话协议",必须符合格式要求。两者目的不同,分开处理,职责清晰。

实现

normalize_messages() 按顺序跑三个独立的 pass,每个 pass 解决一类问题:

normalize_messages(messages)
tool_useend_turnStartAPI Callstop_reason?Execute ToolAppend ResultBreak / Done
messages[] 状态
assistant
原始:{ role, content, _source: "tool-exec", _timestamp: 1234 }
tool_result
清洗后:{ role, content } ← _source / _timestamp 已剥离
length: 2
Step 1:剥离内部字段
遍历每条消息,只保留 role 和 content,过滤掉 _internal / _source / _timestamp 等 agent 内部字段。API 只认协议定义的字段,多余字段会导致 400。
1/3
def normalize_messages(messages: list) -> list:
    """发送给 API 前调用,确保 messages 符合 Anthropic 协议格式。"""
    normalized = []
 
    # ── Pass 1:剥离内部字段 ──────────────────────────────────────────────────
    # agent 内部会给消息附加调试字段(_internal / _source / _timestamp 等)。
    # API 只认协议定义的字段,多余字段会导致 400 报错,必须在发送前过滤掉。
    for msg in messages:
        clean = {"role": msg["role"]}
        if isinstance(msg.get("content"), str):
            clean["content"] = msg["content"]
        elif isinstance(msg.get("content"), list):
            clean["content"] = [
                {k: v for k, v in block.items()
                 if k not in ("_internal", "_source", "_timestamp")}
                for block in msg["content"]
            ]
        normalized.append(clean)
 
    # ── Pass 2:补齐孤立的 tool_use ──────────────────────────────────────────
    # API 要求每个 tool_use 必须有配对的 tool_result(通过 tool_use_id 关联)。
    # 工具超时、用户取消、压缩截断都可能导致 tool_use 没有配对。
    # 先收集所有已有的 tool_use_id → set,再扫 assistant 消息找孤立的 tool_use,
    # 在末尾追加一条 "(cancelled)" 占位 result,让 API 能看到合法的配对。
    existing_results = set()
    for msg in normalized:
        if isinstance(msg.get("content"), list):
            for block in msg["content"]:
                if block.get("type") == "tool_result":
                    existing_results.add(block.get("tool_use_id"))
 
    for msg in normalized:
        if msg["role"] == "assistant" and isinstance(msg.get("content"), list):
            for block in msg["content"]:
                if (block.get("type") == "tool_use"
                        and block.get("id") not in existing_results):
                    normalized.append({"role": "user", "content": [{
                        "type": "tool_result",
                        "tool_use_id": block["id"],
                        "content": "(cancelled)",   # 统一占位符,不区分超时/取消原因
                    }]})
 
    # ── Pass 3:合并连续同角色消息 ────────────────────────────────────────────
    # API 要求 user / assistant 严格交替,不能连续两条同角色。
    # Pass 2 追加的 tool_result 放在 user 消息里,可能紧跟在另一条 user 消息后面,
    # 同样会触发这里的合并。合并方式:把后一条的 content 拼到前一条的 content 列表里。
    merged = [normalized[0]] if normalized else []
    for msg in normalized[1:]:
        if msg["role"] == merged[-1]["role"]:
            prev = merged[-1]
            prev_content = prev["content"] if isinstance(prev["content"], list) \
                else [{"type": "text", "text": prev["content"]}]
            curr_content = msg["content"] if isinstance(msg["content"], list) \
                else [{"type": "text", "text": msg["content"]}]
            prev["content"] = prev_content + curr_content
        else:
            merged.append(msg)
 
    return merged

在 agent loop 中,每次 API 调用前运行:

response = client.messages.create(
    model=MODEL, system=system,
    messages=normalize_messages(messages),  # 规范化后再发送
    tools=TOOLS, max_tokens=8000,
)

note:关键洞察

messages 列表是系统的内部表示,API 看到的是规范化后的副本。两者不是同一个东西。


试一下

教学 demo 提示:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。

下载代码

  • s02-tool-use.zip

    含 code.py · requirements.txt · .env.example

    下载

解压后进入目录运行:

cd s02-tool-use
python3 -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# 编辑 .env,填入 API_KEY / BASE_URL / MODEL_ID
python code.py

试试这些 prompt:

  1. 读取 requirements.txt,告诉我里面列了哪些包
  2. 创建一个叫 test.py 的文件打印 "hello",然后再把它读出来
  3. 找出当前目录里所有的 Python 文件
  4. 分别读取 .env.example 和 requirements.txt,再创建一个 summary.md 做汇总

观察重点

模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?


接下来

现在 Agent 有 5 个专用工具。file tools 受 safe_path 保护,但 bash 不受限制,rm -rf / 还是能跑。

s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?

扩展:深入 Claude Code 源码

以下基于 CC 源码 Tool.tstools.tstoolOrchestration.tstoolExecution.tsStreamingToolExecutor.ts 的核查。

一、工具定义方式

教学版TOOLS 数组 + TOOL_HANDLERS 字典。定义和实现分开。

CC:每个工具是 buildTool() 创建的独立对象,包含 schema、验证、权限、执行。getAllBaseTools() 汇总所有工具。

二、并发安全判断:isConcurrencySafe()

Tool Concurrency

教学版按原始顺序逐个执行,不做并发。

CC 用 isConcurrencySafe(input) 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断:

isReadOnlyisConcurrencySafe
FileReadtruetrue
Globtruetrue
Bash lstruetrue ← 关键差异
Bash rmfalsefalse
TaskCreatefalsetrue ← 改状态但可并发(TaskCreate 在 s12 介绍)

CC 的 Bash tool 的 isConcurrencySafe 等于 isReadOnly——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。

三、分区算法

CC 的 partitionToolCalls()toolOrchestration.ts:91-115)不是分两组,而是把工具调用按连续块分批

[read A, read B, glob *.py, bash "rm x", read C]
  → batch1(并发): [read A, read B, glob *.py]
  → batch2(串行): [bash "rm x"]
  → batch3(并发): [read C]

并发安全的连续块编入同一个 batch,batch 内真正并发执行(toolOrchestration.ts:152-176,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。

四、验证管线

CC 的每个工具调用经过严格的 5 步验证(toolExecution.ts):

  1. Zod schema 验证614-680,教学版用 JSON Schema 替代):参数类型/结构检查
  2. 工具级 validateInput()682-733):参数值验证(如路径是否在工作区内)
  3. PreToolUse hooks800-862,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行
  4. 权限检查921-931,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask
  5. 执行 tool.call()1207-1222

教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。

五、流式工具执行

CC 的 StreamingToolExecutorStreamingToolExecutor.ts)让工具在模型还在生成时就启动——不等模型说完。read_file 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。

换句话说:Claude Code 里工具执行和模型输出是同时进行的。模型还在流式说话,工具已经在后台悄悄跑起来了,等模型把话说完,结果可能已经拿到手了。教学版则严格先后——模型返回完整响应之后,才开始执行工具。代码更简单,逻辑更清楚。

六、工具结果持久化

工具结果最终要塞进 messages 送给模型,但上下文窗口是有限的。一个几千行的文件直接放进去,会快速撑爆 token 预算,又贵又慢,而模型通常只需要其中几行。

CC 的做法是:每个工具有一个 maxResultSizeChars 字段,结果超过这个阈值就不放进 messages,而是写到磁盘临时文件,然后只把"前几行预览 + 文件路径"塞进 messages。模型如果真的需要完整内容,再显式调 read_file 去取。相当于给上下文做分页——按需取用,而不是一股脑全塞进去

FileRead 工具特殊,maxResultSizeChars 设为 Infinity——即永不落盘。原因是:如果读文件的结果超阈值被落盘,模型下次去读那个落盘文件时又会触发落盘,如此循环下去就永远读不到真实内容:

read_file → 结果太大,落盘 → 模型读落盘文件 → 又太大,再落盘 → ...

设为 Infinity 直接绕过这个死循环。


小结

工具使用这一节的核心不是把主循环写得更复杂,而是把模型声明的工具意图交给稳定的分发层处理:工具 schema 负责让模型知道能调用什么,handler map 负责把名字路由到真实动作,tool_result 负责把执行结果送回循环。

专用工具能缩小 bash 的不确定性,但权限系统仍然要在下一节继续补上。


代码下载

本节完整源码,支持官方 Anthropic API 和任何兼容 Anthropic 协议的第三方服务。

  • s02-tool-use.zip

    含 code.py · requirements.txt · .env.example

    下载

这一章对你有帮助吗?

s02:工具使用 | 阿衡的AI笔记