s02_主循环不变,工具能力是怎样一点点长出来的

s02 主循环不变,工具能力是怎样一点点长出来的

很多人第一次看到 s02_tool_use.py,注意力都会先落在 read_filewrite_fileedit_file 这 3 个新工具上。

但如果只盯着“工具变多了”这件事,这一节其实还没看准。

这一版真正补上的,是两个更本质的问题:

  1. 模型发起工具请求后,Python 该怎么稳定地把请求路由到具体实现。
  2. 对话历史越来越复杂之后,发给 API 的消息该怎么保持结构合法。

换句话说,s02 不是单纯给 Agent 多装了几只“手”,而是开始认真处理两件事:能力边界怎么扩消息边界怎么收

源码:s02_tool_use.py

日志:s02_tool_use_20260411_184120.log

核心变化先拎出来

s01 相比,这一节最值得记住的,不是“多了 4 个工具”,而是下面这张对照表:

维度s01s02
工具数量只有 bashbash + read_file + write_file + edit_file
工具分发直接写死在循环里交给 TOOL_HANDLERS 统一路由
文件安全边界基本靠 shell 自己处理文件类工具统一走 safe_path()
消息发送方式基本原样发送发送前先做 normalize_messages()
主循环骨架请求模型 -> 执行工具 -> 回填结果还是这一套

我读完整个文件后的最大感受是:
s02 确实让 Agent 往前走了一步,但复杂度并没有一股脑塞进主循环,而是被拆到了两个更合适的位置:工具层消息层

先把整条链路放进脑子里

别急着一头扎进函数细节,先把这一轮完整链路捋顺:

用户输入

history

normalize_messages

Messages API

stop_reason
是否为 tool_use

extract_text 提取最终文本

终端输出

遍历 response.content

按工具名查 TOOL_HANDLERS

run_bash / run_read / run_write / run_edit

封装 tool_result

这张图里有两个点特别关键:

  1. 工具结果不是打印完就算结束,而是要回写到 history,变成下一轮推理的输入。
  2. 发给模型的 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})

从这个角度看,新增一个工具,本质上通常就是两步:

  1. TOOLS 里把名字、描述、输入参数告诉模型。
  2. TOOL_HANDLERS 里把这个名字映射到真实的 Python 函数。

主循环其实不需要知道你现在有 4 个工具,还是 40 个工具。
它只关心一件事:模型要用工具,就找到处理函数,然后把结果包回去。

为什么不能什么都塞给 bash

如果只有 bash,表面上看好像很多事也能做,但问题很快就会冒出来:

  • 命令粒度太粗,不同平台下细节还会飘。
  • 安全边界很弱,能做的事太多,约束很难收紧。
  • 对模型来说,读文件、写文件、精确编辑,本来就不是同一种动作。

所以这一版把文件操作单独拆成了 3 个工具。它不只是“更方便”,更重要的是“更收口”。

工具作用接口特点为什么单独拆出来
bash执行 shell 命令能力最宽,风险也最大适合兜底,不适合承包一切
read_file读取文件内容支持 limit,可控返回量更适合稳定读取文本
write_file整体写入文件明确是覆盖式写入适合从头生成文件
edit_file定点替换文本必须给出 old_textnew_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_filewrite_fileedit_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 对象、还没完全闭合的工具调用记录。

所以每次请求模型前,代码都会先做一次整理:

原始 history

清洗字段

统计已有 tool_result

补齐缺失结果

合并连续同角色消息

发送给 API

看一下核心代码,会更容易理解它到底在整理什么:

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 件事:

  1. 模型创建文件后,没有立刻假设任务已经完成,而是又调用了一次 read_file 做确认。
  2. 当用户要求补充文档字符串时,模型没有直接硬改,而是先读当前文件内容,再调用 edit_file
  3. edit_file 之所以要求传 old_text,本质上是在鼓励一种更稳妥的工作方式:先观察,再做定点替换。

这其实就是 Agent 和普通“生成一段代码文本”最大的区别。
它不是一次性把答案写完,而是会根据工具返回的真实结果,不断修正下一步动作。

下面这张终端截图,也能直观看到这个过程:

s02_测试输出

从输出顺序上看得很清楚:write_file -> read_file -> edit_file -> read_file
也就是说,这套代码不是让模型“脑补自己改成功了”,而是真的拿工具结果回来做下一步判断。

再用时序图过一遍

具体工具 TOOL_HANDLERS 模型 normalize_messages history 用户 具体工具 TOOL_HANDLERS 模型 normalize_messages history 用户 alt [模型请求工具] [模型不再请求工具] 追加 user message 整理消息 messages + tools assistant content 按工具名分发 执行 run_write / run_read / run_edit / run_bash 返回输出 回填 tool_result 进入下一轮 extract_text 输出最终答案

这个时序图里,最关键的一跳其实就是:
tool_result 回到 history

很多初学者会觉得,“工具都执行完了,为什么还要再塞回消息历史?”
原因很简单,模型下一轮推理并不会直接读取你的 Python 变量,它只能看见你重新喂给它的上下文。

所以,tool_result 不是给人看的日志,它首先是给模型看的事实输入。

这份实现里最值得记住的 5 个细节

  1. TOOLSTOOL_HANDLERS 是两张表,但分别服务于模型和 Python,不能混着看。
  2. read_filewrite_fileedit_file 的价值不只是方便,更是把文件操作收敛到了更容易控边界的接口里。
  3. tool_result 的核心意义不是“显示执行结果”,而是“把事实重新送回下一轮推理”。
  4. normalize_messages() 说明内部历史和 API 输入不是同一回事,系统一复杂,这层适配几乎绕不开。
  5. extract_text() 和绿色终端输出面向的是人,结构化 block 和 tool_result 面向的是模型,这两个视角一定要分开看。

最后收一下

如果说 s01 让我们看到 Agent 为什么要“转一圈又一圈”,那 s02 真正补上的,就是另一层更工程化的东西。

一个能持续推进任务的 Agent,不只是会调模型,还得把工具能力组织起来,把消息历史整理成可靠的协议输入。

所以这一节真正搭起来的,不只是几个新工具,而是 Agent 走向可扩展形态时最先出现的两层骨架:

  • 工具分发层
  • 消息规范化层

反而主循环,是最稳的那一层。

致谢

这一组学习内容的主线整理和启发,受益于 shareAI-lab/learn-claude-code

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

墨1024

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值