文章目录
s02 主循环不变,工具能力是怎样一点点长出来的
很多人第一次看到 s02_tool_use.py,注意力都会先落在 read_file、write_file、edit_file 这 3 个新工具上。
但如果只盯着“工具变多了”这件事,这一节其实还没看准。
这一版真正补上的,是两个更本质的问题:
- 模型发起工具请求后,Python 该怎么稳定地把请求路由到具体实现。
- 对话历史越来越复杂之后,发给 API 的消息该怎么保持结构合法。
换句话说,s02 不是单纯给 Agent 多装了几只“手”,而是开始认真处理两件事:能力边界怎么扩,消息边界怎么收。
日志:s02_tool_use_20260411_184120.log
核心变化先拎出来
和 s01 相比,这一节最值得记住的,不是“多了 4 个工具”,而是下面这张对照表:
| 维度 | s01 | s02 |
|---|---|---|
| 工具数量 | 只有 bash | bash + read_file + write_file + edit_file |
| 工具分发 | 直接写死在循环里 | 交给 TOOL_HANDLERS 统一路由 |
| 文件安全边界 | 基本靠 shell 自己处理 | 文件类工具统一走 safe_path() |
| 消息发送方式 | 基本原样发送 | 发送前先做 normalize_messages() |
| 主循环骨架 | 请求模型 -> 执行工具 -> 回填结果 | 还是这一套 |
我读完整个文件后的最大感受是:
s02 确实让 Agent 往前走了一步,但复杂度并没有一股脑塞进主循环,而是被拆到了两个更合适的位置:工具层 和 消息层。
先把整条链路放进脑子里
别急着一头扎进函数细节,先把这一轮完整链路捋顺:
这张图里有两个点特别关键:
- 工具结果不是打印完就算结束,而是要回写到
history,变成下一轮推理的输入。 - 发给模型的
messages也不是原始历史,而是normalize_messages()整理过的一份副本。
很多人刚开始看 Agent 代码时,很容易把“终端里最后输出了什么”当成主线。
但真正驱动下一轮动作的,从来不是终端输出,而是那份被重新写回历史的结构化消息。
工具变多了,主循环却几乎没动
这一节最顺手的设计,就在于“加工具”这件事并没有把主循环搞乱。
代码里真正负责工具分发的是这一段:
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"]),
}
对应地,模型能看到的是另一套“工具说明书”:
TOOLS = [
{"name": "bash", "description": "执行一条 Shell 命令。", ...},
{"name": "read_file", "description": "读取文件内容。", ...},
{"name": "write_file", "description": "将内容写入文件。", ...},
{"name": "edit_file", "description": "替换文件中的文本。", ...},
]
这里有一组非常值得记住的分工:
TOOLS解决的是“模型知不知道有哪些工具可以用”。TOOL_HANDLERS解决的是“Python 收到工具名后到底该执行谁”。
正因为这组分工清楚,主循环本身才能保持克制。核心部分几乎还是 s01 的样子:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=normalize_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)
tool_output = handler(**block.input) if handler else f"未知工具: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": tool_output,
})
messages.append({"role": "user", "content": results})
从这个角度看,新增一个工具,本质上通常就是两步:
- 在
TOOLS里把名字、描述、输入参数告诉模型。 - 在
TOOL_HANDLERS里把这个名字映射到真实的 Python 函数。
主循环其实不需要知道你现在有 4 个工具,还是 40 个工具。
它只关心一件事:模型要用工具,就找到处理函数,然后把结果包回去。
为什么不能什么都塞给 bash
如果只有 bash,表面上看好像很多事也能做,但问题很快就会冒出来:
- 命令粒度太粗,不同平台下细节还会飘。
- 安全边界很弱,能做的事太多,约束很难收紧。
- 对模型来说,读文件、写文件、精确编辑,本来就不是同一种动作。
所以这一版把文件操作单独拆成了 3 个工具。它不只是“更方便”,更重要的是“更收口”。
| 工具 | 作用 | 接口特点 | 为什么单独拆出来 |
|---|---|---|---|
bash | 执行 shell 命令 | 能力最宽,风险也最大 | 适合兜底,不适合承包一切 |
read_file | 读取文件内容 | 支持 limit,可控返回量 | 更适合稳定读取文本 |
write_file | 整体写入文件 | 明确是覆盖式写入 | 适合从头生成文件 |
edit_file | 定点替换文本 | 必须给出 old_text 和 new_text | 强迫模型先看清再改 |
文件类工具的安全边界来自 safe_path():
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"路径超出工作区范围: {p}")
return path
这段代码很短,但分量很重。
它说明 read_file、write_file、edit_file 不是给 bash 改了个名字,而是真的把能力边界收窄了。
模型想读写文件时,最好走这些更窄的接口,因为这些接口更容易加上路径限制、输出裁剪和行为约束。
反过来看 run_bash(),你会发现它只做了比较轻量的黑名单拦截和超时控制:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(item in command for item in dangerous):
return "错误:已拦截高风险命令"
这里也刚好能看出一件事:
bash 适合做兜底能力,但能力越宽,越难做精细控制;工具越专用,边界反而越容易卡准。
s02 真正补上的,是消息规范化这一层
如果说工具分发解决的是“怎么做”,那 normalize_messages() 处理的就是“怎么把上下文整理成 API 愿意接收的样子”。
这一层特别容易被忽略,但实际非常关键。
代码注释已经把原因说得很清楚:本地维护的消息历史,会比真正发给 API 的消息更“脏”、更“自由”。
里面可能混着内部字段、SDK 返回的 block 对象、还没完全闭合的工具调用记录。
所以每次请求模型前,代码都会先做一次整理:
看一下核心代码,会更容易理解它到底在整理什么:
for msg in messages:
clean = {"role": msg["role"]}
if isinstance(msg.get("content"), str):
clean["content"] = msg["content"]
elif isinstance(msg.get("content"), list):
cleaned_blocks = []
for block in msg["content"]:
if isinstance(block, (ToolUseBlock, ThinkingBlock)):
cleaned_blocks.append(block)
elif isinstance(block, dict):
cleaned_block = {}
for key, value in block.items():
if not key.startswith("_"):
cleaned_block[key] = value
cleaned_blocks.append(cleaned_block)
clean["content"] = cleaned_blocks
normalized.append(clean)
接着它还会补缺失结果、合并连续同角色消息:
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)"}
]})
if msg["role"] == merged[-1]["role"]:
prev["content"] = prev_c + curr_c
我觉得这里最值钱的,不只是“它做了 4 个步骤”,而是它把一条分界线画得很清楚:
history/messages是程序内部视角下的历史。- 真正发给模型的是整理后的 API 视角消息。
这条线一旦画清楚,后面再往系统里加取消、重试、压缩、缓存,思路都会顺很多。
从日志里看,模型不是在“盲改文件”
这一段我觉得特别值得看,因为它能把前面的抽象流程一下子拉回真实运行现场。
其中有一段很说明问题:
[2026-04-11 18:41:41] ToolUseBlock(... name='write_file', input={'path': 'greet.py', ...})
[2026-04-11 18:41:41] 'content': '写入 67 字节到 greet.py'
[2026-04-11 18:41:41] ToolUseBlock(... name='read_file', input={'path': 'greet.py'})
[2026-04-11 18:41:41] 'content': 'def greet(name):\n """向指定名字的人打招呼"""\n return f"Hello, {name}!"'
[2026-04-11 18:42:03] ToolUseBlock(... name='read_file', input={'path': 'greet.py'})
[2026-04-11 18:42:03] ToolUseBlock(... name='edit_file', input={'path': 'greet.py', 'old_text': 'def greet(name):\n """向指定名字的人打招呼"""\n return f"Hello, {name}!"', ...})
[2026-04-11 18:42:03] 'content': '已编辑 greet.py'
这几行日志至少能看出 3 件事:
- 模型创建文件后,没有立刻假设任务已经完成,而是又调用了一次
read_file做确认。 - 当用户要求补充文档字符串时,模型没有直接硬改,而是先读当前文件内容,再调用
edit_file。 edit_file之所以要求传old_text,本质上是在鼓励一种更稳妥的工作方式:先观察,再做定点替换。
这其实就是 Agent 和普通“生成一段代码文本”最大的区别。
它不是一次性把答案写完,而是会根据工具返回的真实结果,不断修正下一步动作。
下面这张终端截图,也能直观看到这个过程:

从输出顺序上看得很清楚:write_file -> read_file -> edit_file -> read_file。
也就是说,这套代码不是让模型“脑补自己改成功了”,而是真的拿工具结果回来做下一步判断。
再用时序图过一遍
这个时序图里,最关键的一跳其实就是:
tool_result 回到 history。
很多初学者会觉得,“工具都执行完了,为什么还要再塞回消息历史?”
原因很简单,模型下一轮推理并不会直接读取你的 Python 变量,它只能看见你重新喂给它的上下文。
所以,tool_result 不是给人看的日志,它首先是给模型看的事实输入。
这份实现里最值得记住的 5 个细节
TOOLS和TOOL_HANDLERS是两张表,但分别服务于模型和 Python,不能混着看。read_file、write_file、edit_file的价值不只是方便,更是把文件操作收敛到了更容易控边界的接口里。tool_result的核心意义不是“显示执行结果”,而是“把事实重新送回下一轮推理”。normalize_messages()说明内部历史和 API 输入不是同一回事,系统一复杂,这层适配几乎绕不开。extract_text()和绿色终端输出面向的是人,结构化 block 和tool_result面向的是模型,这两个视角一定要分开看。
最后收一下
如果说 s01 让我们看到 Agent 为什么要“转一圈又一圈”,那 s02 真正补上的,就是另一层更工程化的东西。
一个能持续推进任务的 Agent,不只是会调模型,还得把工具能力组织起来,把消息历史整理成可靠的协议输入。
所以这一节真正搭起来的,不只是几个新工具,而是 Agent 走向可扩展形态时最先出现的两层骨架:
- 工具分发层
- 消息规范化层
反而主循环,是最稳的那一层。
致谢
这一组学习内容的主线整理和启发,受益于 shareAI-lab/learn-claude-code。
396

被折叠的 条评论
为什么被折叠?



