来源与致谢
本节整理自 Learn Claude Code 官方站点 s02:工具使用 与 GitHub 源项目
shareAI-lab/learn-claude-code · s02_tool_use
,按 MIT License 授权整理;同时迁入阿衡学习笔记 s02-工具使用.md
的补充理解。本站内容为非官方学习笔记,不代表 shareAI-lab 或 Anthropic
官方立场。
学习目标
- 区分工具调用声明、路由和真实动作。
- 用 dispatch map 解释工具如何接入 Agent 循环。
- 说明工具 schema 为什么会影响模型选择。
- 解释为什么不能所有操作都交给
bash。 - 说明
safe_path()如何挡住路径逃逸。 - 判断复杂系统里为什么要在发给 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 命令。
这样做有三个问题:
-
cat截断不可预测cat是把文件原样输出到终端,文件很大时输出会被截断,LLM 拿到的是残缺内容,它不知道自己没看完。换成read_file工具,你可以在 handler 里加offset/limit参数,精确控制读多少行。 -
sed遇到特殊字符就崩LLM 生成的
sed命令里一旦出现引号、斜杠、换行,shell 转义就容易出错,命令直接报错或者产生错误结果。换成edit_file工具,handler 里用 Python 字符串操作,不经过 shell,特殊字符不再是问题。 -
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 pathLLM 就算想读
/etc/passwd,handler 直接拒掉,bash 根本没机会执行。专用工具(
read_file,write_file)可以在工具层面做路径沙箱。
tip:关键洞察
加工具不需要改主循环逻辑。
全局视角:工具分发
Harness 层: 工具分发 — 扩展模型能触达的边界。
s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:run_bash() 替换为 TOOL_HANDLERS[block.name]() 查表分发。
给 Agent 加一个工具只需要做两件事:
- 定义工具:在
TOOLS数组里加一条描述 - 注册处理函数:在
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 协议有三条硬性约束:
- 每个
tool_use块必须有匹配的tool_result(通过tool_use_id关联) user/assistant消息必须严格交替(不能连续两条同角色)- 只接受协议定义的字段(内部元数据会导致 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 中间,配对就断了。
所以"规范化"就是在每次发给 API 之前,扫一遍 messages,把这些残缺的、不配对的、顺序错乱的条目修复或裁掉,确保发出去的格式 API 一定能接受。
为什么不在写入时就格式化,而要等到发送前?
有三个原因:
-
写入时不知道结果
tool_use写进 messages 的时候,工具还没跑。超时和取消发生在执行阶段,没法在写入时就"预补"配对,因为配对还没发生。 -
内部 messages 有额外用途
agent 需要看到"tool_use 有、tool_result 没有"才知道哪一步崩了,才能决定要不要重试。如果写入时就把中间状态抹掉,这些调试和重试信息就丢了。
-
压缩是事后操作
压缩发生在消息已经合法写入之后,把一段历史删掉换成摘要,可能制造出新的不合法结构。没有办法在原始写入时预防这个问题。
一句话
内部 messages 是"事件日志",记录真实发生了什么;发给 API 的 messages 是"对话协议",必须符合格式要求。两者目的不同,分开处理,职责清晰。
实现
normalize_messages() 按顺序跑三个独立的 pass,每个 pass 解决一类问题:
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:
- 读取 requirements.txt,告诉我里面列了哪些包
- 创建一个叫 test.py 的文件打印 "hello",然后再把它读出来
- 找出当前目录里所有的 Python 文件
- 分别读取 .env.example 和 requirements.txt,再创建一个 summary.md 做汇总
观察重点
模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?
接下来
现在 Agent 有 5 个专用工具。file tools 受 safe_path 保护,但 bash 不受限制,rm -rf / 还是能跑。
s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?
扩展:深入 Claude Code 源码
以下基于 CC 源码
Tool.ts、tools.ts、toolOrchestration.ts、toolExecution.ts、StreamingToolExecutor.ts的核查。
一、工具定义方式
教学版:TOOLS 数组 + TOOL_HANDLERS 字典。定义和实现分开。
CC:每个工具是 buildTool() 创建的独立对象,包含 schema、验证、权限、执行。getAllBaseTools() 汇总所有工具。
二、并发安全判断:isConcurrencySafe()
教学版按原始顺序逐个执行,不做并发。
CC 用 isConcurrencySafe(input) 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断:
| isReadOnly | isConcurrencySafe | |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
Bash ls | true | true ← 关键差异 |
Bash rm | false | false |
| TaskCreate | false | true ← 改状态但可并发(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):
- Zod schema 验证(
614-680,教学版用 JSON Schema 替代):参数类型/结构检查 - 工具级 validateInput()(
682-733):参数值验证(如路径是否在工作区内) - PreToolUse hooks(
800-862,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行 - 权限检查(
921-931,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask - 执行 tool.call()(
1207-1222)
教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。
五、流式工具执行
CC 的 StreamingToolExecutor(StreamingToolExecutor.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