工具池与分发

D2 学习笔记:工具池与分发(2026-10-28)

对应计划:《15天必听网课与Agent实践计划.md》D2 ------ s02_tool_use,工具池与分发;本地对照 registry_agent.py(Tool → _infer_schema → ToolRegistry → _execute)。

一、今天学习的两个文件

序号 文件 位置 看了哪部分
1 code.py(s02_tool_use) test_LLM/s02_tool_use/ 全文:五个工具、TOOLS、TOOL_HANDLERS、safe_path、agent_loop
2 registry_agent.py Agent最小架构问答整理_2026-10-14/code/ 全文,重点 Tool、ToolRegistry、_infer_schema、@tool、_execute

自己动手的改动:

文件 改动
s02_tool_use/code.py 新增 grep 工具(两版);agent_loop 分发处加 try/except;工具调用和最终回答之间加分隔线;让 agent 自己把 run_glob 改成走 safe_path
registry_agent.py 用 @tool 新增 grep 工具

一句话总结今天:s01 的循环不变,s02 学的是"工具怎么告诉模型、模型的请求怎么分发到函数";registry_agent 学的是"怎么让 schema 和分发表自动生成"。


二、s02 code.py:需要掌握什么

2.1 核心思想

循环结构不变,只有工具的注册和分发在增长。

s01 在循环里写死了 run_bash(block.input["command"]);s02 改成按名字查字典:

python 复制代码
TOOL_HANDLERS = {"bash": run_bash, "read_file": run_read, "write_file": run_write,
                 "edit_file": run_edit, "glob": run_glob, "grep": run_grep}

handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)

加一个工具,agent_loop 一个字都不用改。

2.2 掌握程度分档

必须会写(合上代码能自己写出来)

  • 一个工具定义的三部分:name、description、input_schema
  • TOOL_HANDLERS 字典 + handler(**block.input) 分发
  • tool_result 带 tool_use_id,所有结果放进一条 user 消息

必须看懂(知道为什么这么写)

  • safe_path 怎么防止 ../ 跑出工作目录
  • 为什么出错要返回 "Error: ..." 字符串而不是抛异常
  • 为什么要截断输出(limit、50000、200)

知道有就行(不用花时间)

  • 开头的 readline 设置、\033[33m 颜色代码、load_dotenv,和 agent 原理无关

自测标准 :不看代码,给它加一个新工具,只改 TOOLS 和 TOOL_HANDLERS 两处就能跑通。

2.3 command 到底是什么(我一开始没理解的地方)

python 复制代码
{"name": "bash", "description": "Run a shell command.",
 "input_schema": {"type": "object",
                  "properties": {"command": {"type": "string"}},
                  "required": ["command"]}}

command 只是一个参数名,是我们自己起的名字。 叫 cmd、abc 都可以,两边对上就行。

可以把工具定义想成一张表格 :表格名叫 bash,上面只有一个空格要填,空格标题是 command。

完整过程:

复制代码
你:"看看有哪些文件"
        ↓
模型:"请用 bash,command = 'ls'"          ← 模型只"说",不"做"
        ↓
你的代码:block.input["command"] → "ls" → run_bash("ls")   ← 真正执行的是你
        ↓
结果 "s01.py s02.py .env" 发回模型
        ↓
模型:"当前目录有三个文件......"

模型返回的块:

python 复制代码
block.type  = "tool_use"
block.id    = "toolu_01ABC..."
block.name  = "bash"
block.input = {"command": "ls"}     # ← 模型把表格填好了,就是一个普通字典

output = run_bash(block.input["command"]) 拆开:

python 复制代码
block.input                 # {"command": "ls"}
block.input["command"]      # "ls"   ← 普通的字典取值
run_bash("ls")              # 真正在电脑上执行 ls

验证理解 :如果 schema 改成 "properties": {"cmd": ...},模型返回的就是 {"cmd": "ls"},代码也要改成 block.input["cmd"]。

2.4 分发两行代码逐行解释

python 复制代码
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"

第一行:按名字找函数

  • TOOL_HANDLERS 里 run_bash 后面没有括号 ,存的是函数本身,不是调用结果。所以这个字典是"名字 → 函数"的对照表。
  • 用 .get() 而不是 []:
python 复制代码
TOOL_HANDLERS["xyz"]       # 名字不存在 → KeyError,程序崩溃
TOOL_HANDLERS.get("xyz")   # 名字不存在 → 返回 None,程序继续

模型偶尔会编一个不存在的工具名,用 .get() 程序不会崩。

第二行:条件表达式,等价于:

python 复制代码
if handler:
    output = handler(**block.input)
else:
    output = f"Unknown: {block.name}"

** 解包:把字典拆成"参数名=值"传给函数。

python 复制代码
block.input = {"path": "s02.py", "limit": 10}
handler(**block.input)
# 等价于 run_read(path="s02.py", limit=10)

同一行代码能调用任何工具:调哪个函数由 block.name 决定,参数由 block.input 决定。

模型返回 handler output
name="bash", input={"command":"ls"} run_bash run_bash(command="ls") 的结果
name="glob", input={"pattern":"*.py"} run_glob run_glob(pattern="*.py") 的结果
name="delete", input={...} None "Unknown: delete"

由此推出一条规则:schema 里的属性名必须和函数参数名完全一致 ,否则 ** 解包对不上。

2.5 分发处的 try/except(我自己改过的地方)

问题 :模型传了函数不认识的参数,比如 {"path": "a.txt", "encoding": "gbk"},run_read(path=..., encoding=...) 会抛 TypeError,程序崩溃。

为什么函数内部的 try 接不住? Python 执行调用时先检查参数能不能对上函数定义,对不上当场报错,这时函数体一行都没执行:

python 复制代码
def run_read(path, limit=None):
    try:            # ← 参数错了,根本走不到这里
        ...

所以必须写在 agent_loop 里,包在调用那一行外面:

python 复制代码
handler = TOOL_HANDLERS.get(block.name)
if handler is None:
    output = f"Unknown: {block.name}"
else:
    try:
        output = handler(**block.input)
    except Exception as e:
        output = f"Error: {e}"

(try 没法塞进条件表达式,所以拆成了 if/else。)

修完后模型会收到 Error: run_read() got an unexpected keyword argument 'encoding',下一轮通常会自己改正。顺带的好处:新工具忘了写 try,程序也不会整个退出。

2.6 safe_path 怎么防止 ../ 跑出工作目录

python 复制代码
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
  • .. 表示上一级目录,一直往上能到 C:\,整台电脑的文件都能碰到。
  • 第一步 resolve():把 .. 真正算出来。
python 复制代码
WORKDIR / "../../secret.txt"
# C:\...\test_LLM\s02_tool_use\..\..\secret.txt   ← 看起来还在工作目录里,是假象
(WORKDIR / "../../secret.txt").resolve()
# C:\Users\lily5\Documents\ChatGPT\secret.txt      ← 真实位置
  • 第二步 is_relative_to():检查真实位置是否还在工作目录里面。
模型传入 resolve() 后 在工作目录内吗 结果
code.py ...\s02_tool_use\code.py 是 放行
images/../code.py ...\s02_tool_use\code.py 是 放行(绕一圈又回来)
../../secret.txt ...\ChatGPT\secret.txt 否 Path escapes workspace

比喻 :工作目录是房间,safe_path 是门口保安。不管你说"往左两步再后退三步"多绕,他先算你最终站在哪,再看在不在房间里。

局限 :safe_path 只保护文件工具。run_bash("cat ../../etc/passwd") 不经过它,照样能读。dangerous 黑名单也很容易绕过,只是演示。

2.7 为什么出错要返回 "Error: ..." 字符串而不是抛异常

抛异常 返回字符串(s02 的写法)
模型读 cod.py(拼错) FileNotFoundError,整个程序崩溃,对话历史全丢 返回 Error: No such file... 作为 tool_result
模型能做什么 什么都做不了 看到错误 → glob 看看有哪些文件 → 发现是 code.py → 重试

对 agent 来说,错误不是"故障",而是"信息"。

普通程序出错是程序员去修;agent 出错是模型自己看报错、自己修。所以错误必须以模型能看到的形式送回去。

比喻:助手去拿文件发现不在,应该回来说"没找到,要不要换个名字",而不是当场晕倒。

2.8 为什么要截断输出

关键事实 :工具输出会原封不动进 messages,而 messages 每一轮都完整发给模型,一直跟到对话结束。

不截断的后果(比如 type huge.log 10 万行):

  1. 撑爆上下文:API 直接报错,对话进行不下去
  2. 烧钱、变慢:按 token 计费,之后每一轮都要重发
  3. 模型变笨:信息太多反而找不到重点
位置 截断方式 防什么
run_bash out[:50000] 命令输出太长
run_read limit 参数 让模型能"先看个开头"
run_glob / run_grep 最多 200 条 **/* 在大项目里可能匹配几万个

截断要留个话:

python 复制代码
lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
shown.append("... (more matches omitted; narrow the pattern)")

模型看到提示就知道"只看到了一部分",会缩小范围。悄悄截断不提示,模型会以为看到的就是全部,基于不完整信息得出错误结论。

三点合起来:

机制 防的是 一句话
safe_path 模型碰到不该碰的文件 管住能去哪
返回 "Error: ..." 一个小错误让整个程序崩溃 让模型能自己改错
截断输出 一次大输出拖垮整个对话 管住能拿多少

2.9 bash 什么都能干,为什么还要专用工具?(我的疑问)

操作 用 bash 用专用工具
读 cat a.py read_file
写 echo "..." > a.py write_file
改 sed -i 's/旧/新/' a.py edit_file
查 find . -name "*.py" glob

通俗地说:bash 是"口头吩咐",专用工具是"填表格"。 口头吩咐什么都能说,但容易说错听错;表格只能做规定的事,但每格填什么清清楚楚。

原因 1(最重要):bash 写文件时引号和特殊符号会出问题

要写入 print("价格: $100"):

bash 复制代码
echo "print("价格: $100")" > a.py
  • 内容里的 " 和外面的 " 撞了
  • $1 被 shell 当成变量替换成空

文件里实际是 print(价格: 00),代码被悄悄写坏,没有任何报错 。用 write_file,内容作为 JSON 字符串传过来,不经过 shell,原样写入。

原因 2:sed 比要改的内容还难写

把 src/a.py 改成 src/b.py:sed -i 's/src/a.py/src/b.py/' 是错的(/ 冲突、. 是任意字符),要写成 s/src\/a\.py/src\/b\.py/;macOS 和 Linux 的 -i 写法还不一样。sed 找不到原文时静默什么都不做 。edit_file 所见即所得,找不到会明确返回 Error: text not found。

原因 3:安全,bash 管不住

python 复制代码
run_read("../../etc/passwd")      # → Error: Path escapes workspace   ✓
run_bash("cat ../../etc/passwd")  # → 直接读出来了                    ✗

专用工具参数是结构化的,很容易检查;bash 参数是任意命令,几乎没法逐一检查。以后做"读放行、写要确认"的权限控制,专用工具一行判断就能实现。

原因 4:跨平台和输出控制

  • Windows cmd 里没有 cat、sed、find(ENVIRONMENT_PROMPT 专门处理这件事;我今天实际撞上了 'sed' is not recognized)
  • cat 10 万行会撑爆上下文,read_file 有 limit

bash 负责"什么都能干",专用工具负责"常做的事做得又稳又安全"。

读写改查交给专用工具;跑测试、装依赖、执行脚本交给 bash。系统提示里的 "prefer dedicated file tools for file operations" 就是这个分工。Claude 自己的 Read、Write、Edit、Glob、Bash 也是同样的设计。

2.10 agent_loop 本身的四个点

1. messages 长什么样(问一次"搜索 safe_path",调了两次工具):

python 复制代码
[
  {"role": "user",      "content": "搜索 safe_path"},
  {"role": "assistant", "content": [tool_use(bash, {"command": "grep ..."})]},
  {"role": "user",      "content": [tool_result(id=..., content="code.py:79: ...")]},
  {"role": "assistant", "content": [tool_use(read_file, {"path": "code.py"})]},
  {"role": "user",      "content": [tool_result(id=..., content="#!/usr/bin/env ...")]},
  {"role": "assistant", "content": [text("safe_path 只出现在......")]},
]
  • user 和 assistant 严格交替;工具结果放在 user 里,因为 API 只有这两种角色
  • 每一轮都把整个列表发给模型,模型的"记忆"全靠它
  • history 跨问题保留,所以能接着上次聊

2. 循环什么时候停 :模型这一轮没调用任何工具。是模型决定停,不是代码。 原版是 while True,没有最大步数上限。

3. 为什么只打印最后一条消息 :history[-1] 是最终回答。模型中间轮次说的话("我先读一下文件")存进了 history,但从没打印出来。

4. run_bash 的参数:

参数 作用
shell=True 交给系统 shell(我电脑上是 cmd.exe),所以没有 sed
cwd=WORKDIR 在工作目录里执行
capture_output=True 把输出抓回来交给模型,而不是打印到屏幕
r.stdout + r.stderr 正常输出和错误输出都交给模型
errors="replace" 解码不了的字符用 � 代替不崩溃。中文 README 乱码就和它有关:cmd 用 GBK,文件是 UTF-8
timeout=120 卡住超过 2 分钟强制结束

三、我对 s02 的实践记录

3.1 新增 grep 工具(第一版)

python 复制代码
def run_grep(pattern: str, path: str) -> str:
    try:
        file_path = safe_path(path)
        lines = file_path.read_text(encoding="utf-8").splitlines()
        matches = [line for line in lines if pattern in line]
        if not matches:
            return "(no matches)"
        return "\n".join(matches[:200]) + (f"\n... ({len(matches) - 200} more matches)" if len(matches) > 200 else "")
    except Exception as e:
        return f"Error: {e}"

做对的 :三处都改到了(函数、TOOLS、TOOL_HANDLERS);用了 safe_path;截断 200 并提示;出错返回字符串;schema 属性名和参数名一致。

可以改进的三点:

  1. 没有行号:模型 grep 到之后通常要去读或改附近代码,不知道第几行
  2. 只能搜一个文件:最常见的用法是"整个项目里哪里用了 X"
  3. description 太简单 :没说是普通文本还是正则,模型可能传 def \w+ 结果什么都搜不到

3.2 grep 第二版(支持目录 + 行号)

python 复制代码
def run_grep(pattern: str, path: str = ".") -> str:
    try:
        target = safe_path(path)
        files = [target] if target.is_file() else sorted(
            p for p in target.rglob("*") if p.is_file())
        matches = []
        for f in files:
            try:
                lines = f.read_text(encoding="utf-8").splitlines()
            except (UnicodeDecodeError, OSError):
                continue  # 跳过二进制文件
            rel = f.relative_to(WORKDIR)
            for i, line in enumerate(lines, 1):
                if pattern in line:
                    matches.append(f"{rel}:{i}: {line}")
        ...

schema 里 path 从 required 去掉,因为函数给了默认值 ".":不在 required 里的参数,函数里必须有默认值。

3.3 加分隔线

在 agent_loop(history) 之后、打印最终回答之前加一行:

python 复制代码
agent_loop(history)
print("\n" + "=" * 30 + " 回答 " + "=" * 30 + "\n")

原因:agent_loop 运行期间打印的是工具名和结果("思考部分");返回后打印的是 history[-1](回答部分),两者之间是天然分界点。

3.4 运行中观察到的现象

现象 1:模型没用我写的 grep,而是用 bash 搜索。 问"搜索 safe_path",显示的是 > bash 而不是 > grep。

→ 模型选哪个工具全靠 description 。可以改成 "... Prefer this over bash grep/findstr. Returns file:line: content." 再试。

现象 2:撞上 'sed' is not recognized as an internal or external command。 Windows cmd 没有 sed,模型看到错误后改用 edit_file。这同时验证了两件事:跨平台问题真实存在;错误以字符串返回,模型能换方法继续。

现象 3:让 agent 自己把 run_glob 改成走 safe_path,它改了自己的源码。 我觉得很荒谬,讨论后的结论:

  1. 它不知道自己在改"自己" :WORKDIR = Path.cwd(),我在 s02_tool_use 里启动,code.py 恰好在工作目录里,对模型来说只是一个普通文件
  2. 改了不会立刻生效:Python 启动时已把代码读进内存,新代码要重启才生效。是"给明天的自己改说明书"
  3. 真正的安全问题 :safe_path 保护的是工作目录,而 safe_path 本身就写在工作目录里。 模型可以用 edit_file 把 dangerous 黑名单或 safe_path 的检查删掉,下次启动防护就没了。就像把保险柜钥匙放在保险柜里
  4. 解决 :agent 代码和项目分开放,在项目目录里启动 python ..\s02_tool_use\code.py,这样 code.py 在工作目录外。但 bash 仍不受 safe_path 约束,真正的 agent 还要加权限确认或沙箱(D3)

四、registry_agent.py:需要掌握什么

4.1 它解决什么问题

回忆 s02 加 grep 改了几处:①写函数 ②在 TOOLS 手写 schema ③在 TOOL_HANDLERS 注册。三处,名字还得完全对上。 这个文件把三处变成一处:

python 复制代码
@tool("计算数学表达式,例如 '(3+5)*2'。")
def calculator(expression: str) -> str:
    ...

文件分两个阶段:

  • 阶段 1 (26--45 行):手写 schema + if/elif 分发,是反面例子,比 s02 的字典还差
  • 阶段 2 (51--120 行):Tool + ToolRegistry + @tool

4.2 三个部件的比喻

部件 比喻 作用
Tool 员工档案卡 装下一个工具的全部信息
ToolRegistry 通讯录 按名字存放和查找档案卡
@tool + _infer_schema 自动登记员 写完函数,自动填档案卡、放进通讯录

4.3 Tool:档案卡(51--71 行)

python 复制代码
@dataclass
class Tool:
    name: str
    description: str
    func: Callable[..., Any]
    parameters: dict
  • @dataclass 自动生成 __init__,只要列出字段就能 Tool(name=..., description=..., func=..., parameters=...)
  • Callable[..., Any] 意思就是"这里放一个函数"
  • func 存的是函数本身(没有括号)
s02 这里
TOOLS 里的 name/description/input_schema Tool 的 name/description/parameters
TOOL_HANDLERS 里的函数 Tool.func
handler(**block.input) Tool.call(**kwargs)

call 方法的两个 **,作用相反:

python 复制代码
def call(self, **kwargs) -> Any:
    return self.func(**kwargs)

t.call(expression="1+1")
# def call(self, **kwargs) 里的 ** :打包 → kwargs = {"expression": "1+1"}
# self.func(**kwargs) 里的 **     :解包 → calculator(expression="1+1")

to_openai_schema :把档案卡翻译成 OpenAI 认识的 JSON 格式。以后换 Anthropic,只需加一个 to_anthropic_schema(),业务函数一行不改。

4.4 ToolRegistry:通讯录(74--92 行)

python 复制代码
self._tools: dict[str, Tool] = {}

核心就是一个字典,和 s02 的 TOOL_HANDLERS 同一个思路 。区别:s02 存"名字 → 函数",这里存"名字 → 整张档案卡"。_tools 的下划线表示"内部使用"。

方法 作用 要点
register 登记 检查重名。字典同名 key 会悄悄覆盖 ,没检查的话第一个工具凭空消失。用 raise 而不是返回字符串,因为这是程序员写错了,要在启动时暴露,不需要交给模型
get 查找 .get() 找不到返回 None 不崩溃
to_openai_tools 导出给 API 遍历所有档案卡翻译成列表,替代了 s02 手写的整个 TOOLS
names 列出工具名 用在报错里:"没有工具 xxx,可用:..."

4.5 _infer_schema:自动填写参数说明(100--108 行)

我的理解:就是"看着函数的第一行抄作业"。

复制代码
def grep(path: str, text: str, max_lines: int = 20)
         ────┬────  ────┬────  ─────────┬─────────
         string,必填  string,必填   integer,选填(因为有 = 20)

三步:

python 复制代码
hints = get_type_hints(func)
# 第一步:读类型注解 {"path": str, "text": str, "max_lines": int, "return": str}
#         "return" 是返回值类型,下一步只遍历参数,会被自然忽略

for pname, p in inspect.signature(func).parameters.items():
    # 第二步:读函数签名,p.default 记录默认值
    props[pname] = {"type": _PY2JSON.get(hints.get(pname, str), "string")}
    if p.default is inspect.Parameter.empty:
        required.append(pname)
    # 第三步:逐个参数判断类型和是否必填

类型那一行有两层查找:

python 复制代码
hints.get(pname, str)        # 查 Python 类型,没写注解就当 str
_PY2JSON.get(..., "string")  # 翻译成 JSON 类型,翻译不了就当 "string"

为什么用 inspect.Parameter.empty 而不是 None? 默认值本身可能就是 None(def foo(x: str = None),这是选填)。需要专门的标记区分"没有默认值"和"默认值是 None"。

参数 类型注解 JSON 类型 默认值 必填?
path str "string" 无 ✓
text str "string" 无 ✓
max_lines int "integer" 20 ✗

s02 的规则"不在 required 里的参数,函数里必须有默认值",以前靠我手动保证,现在代码自动保证。

隐藏局限 :list[str]、int | None、Optional[int] 在 _PY2JSON 里查不到,悄悄退回 "string" 。所以 @tool 留了 parameters 参数,可以手写 schema 覆盖。

4.6 Agent 类(157 行往后)

循环和 s02 一模一样:

python 复制代码
for _ in range(max_steps):            # ← 有步数上限
    resp = 请求模型
    把模型回复存进 messages
    if 没有工具调用:
        return 回答
    for 每个工具调用:
        执行,把结果存进 messages
return "达到最大步数"

messages 放进了类里 :self.messages 存在对象身上,只要 agent 对象还在,记忆就一直在。s02 是外面的 history 列表传进 agent_loop,效果一样。

_execute 的三层防护:

python 复制代码
t = self.registry.get(name)
if t is None:                                   # ① 工具不存在
    return f"错误:没有工具 {name},可用:{self.registry.names()}"
try:
    args = json.loads(arguments or "{}")       # ② 参数不是合法 JSON
except json.JSONDecodeError as e:
    return f"错误:参数不是合法 JSON:{e}"
try:
    return str(t.call(**args))                 # ③ 执行出错(含参数名对不上)
except Exception as e:
    return f"工具执行出错:{type(e).__name__}: {e}"

第②层是在执行工具之前就拦下,工具函数根本没被调用。

了解即可:

  • _assistant_dict:把 OpenAI SDK 返回的对象转成普通字典,才能存进 messages 下次发回去
  • Agent.__new__(Agent):创建对象但跳过 __init__,不连接 OpenAI,不需要 API key 也能测试工具
  • 运行离线模式:python registry_agent.py --offline

4.7 和 s02 对比,它的不足

问题 位置 s02 怎么做的
read 没有路径保护,../../ 随便读 read 函数 有 safe_path
bash 没有危险命令拦截 bash 函数 有 dangerous 黑名单
eval 执行任意表达式,限制 builtins 也不完全安全 calculator 没有这个工具
自动生成的 schema 没有参数说明 _infer_schema 手写时可以加 description

截断方向不同:这里 bash 用 [-4000:] 保留末尾 ;s02 用 [:50000] 保留开头。报错信息通常在最后,保留末尾更容易看到错误原因。

4.8 我用 @tool 加的 grep

python 复制代码
@tool("在文件或目录中搜索普通文本(不是正则),返回 文件:行号: 内容。")
def grep(path: str, text: str, max_lines: int = 20) -> str:
    lines = []
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            for i, line in enumerate(f, 1):
                if text in line:
                    lines.append(f"{path}:{i}: {line.rstrip()}")
                    if len(lines) >= max_lines:
                        break
    except Exception as e:
        lines.append(f"错误:{type(e).__name__}: {e}")
    return "\n".join(lines) or "(未找到)"

离线测试结果:

复制代码
已注册工具: ['calculator', 'bash', 'read', 'grep']
grep 的 schema: {"properties": {"path": "string", "text": "string", "max_lines": "integer"},
                 "required": ["path", "text"]}
grep(path=".", ...)       → 错误:IsADirectoryError: Is a directory: '.'
grep(path="nope.py", ...) → 错误:FileNotFoundError

只写了一个函数,schema 和注册都没碰,max_lines 自动变成选填。 和 s02 改三处一对比,区别很明显。

两个问题:

  1. description 说能搜目录,代码只能搜文件 。比代码 bug 更隐蔽:模型完全信任 description,看到"文件或目录"就会传目录然后失败。description 和实际行为不一致,等于骗了模型。
  2. 截断时没有提示 :搜到第 20 条就 break,模型不知道后面还有没有。

五、重点:函数 / schema / 分发表 / 装饰器(我最容易混淆的部分)

5.1 混淆的根源

s02 里三样东西都是我亲手写在代码里的,能直接看到;registry_agent 里只有"函数"是我写的,另外两样是程序运行时自动生成的,代码里看不到它们的样子。

5.2 三样东西分别在哪(以 grep 为例)

s02(code.py) registry_agent.py
① 函数本身 137 行 def run_grep(...) 140 行 def grep(...)
② schema 162 行 TOOLS 列表里手写的那一项 没有手写 。100 行 _infer_schema 读函数签名自动生成,存在 Tool.parameters 里
③ 分发表 181 行 TOOL_HANDLERS 字典,手写 "grep": run_grep 没有手写 。是 76 行 self._tools 字典,由装饰器自动往里放

在 registry_agent 里翻遍代码也找不到一行 "grep": grep 或 grep 的 schema JSON,它们只存在于程序运行后的内存里。

5.3 程序启动后内存里长什么样

python 复制代码
registry._tools = {
    "calculator": Tool(name="calculator", description="...", func=calculator, parameters={...}),
    "bash":       Tool(name="bash",       description="...", func=bash,       parameters={...}),
    "read":       Tool(name="read",       description="...", func=read,       parameters={...}),
    "grep":       Tool(
                      name="grep",
                      description="在文件或目录中搜索普通文本...",
                      func=grep,                       # ← ① 函数本身
                      parameters={                     # ← ② schema
                          "type": "object",
                          "properties": {"path": {"type": "string"},
                                         "text": {"type": "string"},
                                         "max_lines": {"type": "integer"}},
                          "required": ["path", "text"]},
                  ),
}
#   ↑ 整个字典就是 ③ 分发表

s02 里分散在三处的东西,在这里被装进了同一个字典的同一个格子里:

  • 字典本身 = s02 的 TOOL_HANDLERS(③)
  • 格子里的 func = s02 的 run_grep(①)
  • 格子里的 name + description + parameters = s02 TOOLS 里那一项(②)

5.4 装饰器是干嘛的

我的理解(确认正确) :类似于写了一个 Python 脚本,读取到函数的时候,自动把 schema 和分发表全部写好。

装饰器就是往这个字典里放东西的那只手。 s02 是我自己的手写 TOOLS 和 TOOL_HANDLERS;这里是 @tool 替我写。

写了这个:

python 复制代码
@tool("在文件中搜索文本")
def grep(path: str, text: str, max_lines: int = 20) -> str:
    ...

相当于自动补写了 s02 里我手写的两处:

python 复制代码
# 自动补写 ①:模型需要的 schema(相当于 s02 的 TOOLS)
{"name": "grep", "description": "在文件中搜索文本",
 "parameters": {"type": "object",
                "properties": {"path": {"type": "string"},
                               "text": {"type": "string"},
                               "max_lines": {"type": "integer"}},
                "required": ["path", "text"]}}

# 自动补写 ②:分发表(相当于 s02 的 TOOL_HANDLERS)
"grep": grep

分工 :_infer_schema 负责写 schema;@tool 负责把 schema 和函数打包成 Tool 放进 registry,_infer_schema 是它调用的帮手。

没有 @tool,函数只是普通函数,字典里不会有它,模型也看不到它。 (可以删掉 @tool(...) 跑 --offline 验证。)

5.5 装饰器代码拆解

python 复制代码
def tool(description: str, name: str | None = None, parameters: dict | None = None):
    def deco(func: Callable) -> Callable:
        registry.register(Tool(
            name=name or func.__name__,
            description=description,
            func=func,
            parameters=parameters or _infer_schema(func),
        ))
        return func
    return deco

为什么是两层函数? 因为装饰器要带参数(description)。Python 实际做的是:

python 复制代码
@tool("计算数学表达式")
def calculator(expression: str) -> str: ...

# 等价于
def calculator(expression: str) -> str: ...
deco = tool("计算数学表达式")       # ① 调用外层,传入配置,返回内层 deco
calculator = deco(calculator)       # ② 调用内层,传入函数,完成注册
  • 外层 tool:接收配置(description、name、parameters)
  • 内层 deco:接收函数,干活
  • 内层能用到外层的 description,叫闭包:外层先把 description 装进 deco 的口袋,deco 带着它出门

deco 做的四件事:

代码 意思
name or func.__name__ 手写了 name 就用手写的,没写就用函数名(func.__name__ 是 Python 自带属性)
parameters or _infer_schema(func) 手写了 schema 就用手写的,没写就自动推断(解决 list[str] 局限)
registry.register(Tool(...)) 创建档案卡并登记,register 内部执行 self._tools["grep"] = 这个Tool
return func 原样返回函数,calculator("1+1") 在普通代码里照样能用

看到"自动写"的方法 :在 deco 里加 print(f"读到函数 {func.__name__},自动生成:", _infer_schema(func)),运行 --offline,还没和模型对话就先打印出四行,证明登记发生在程序启动、读到函数定义时。

5.6 return func 删掉会怎样(易错)

python 复制代码
def deco(func):
    registry.register(Tool(..., func=func, ...))   # ① 先把原函数存进 registry
    return func                                     # ② 再返回(删了也不影响 ①)
调用方式 结果
模型通过 registry 调用 ✓ 正常,走的是 registry.get → Tool.func → 原函数
我在代码里写 calculator("1+1") ✗ 'NoneType' object is not callable,因为 calculator = deco(calculator) 拿到了 None

很隐蔽:agent 跑起来一切正常,只有别处直接调用时才炸。

5.7 分发流程逐步对照

步骤 s02 registry_agent
启动时准备 我手写好 TOOLS 和 TOOL_HANDLERS @tool 自动把每个函数装进 registry._tools
告诉模型有哪些工具 195 行 tools=TOOLS 170 行 tools=self.registry.to_openai_tools()
模型说要用 grep block.name = "grep" name = "grep"
按名字查 208 行 TOOL_HANDLERS.get("grep") → 得到函数 185 行 self.registry.get("grep") → 得到 Tool 对象
执行 213 行 handler(**block.input) 193 行 t.call(**args) → 60 行 self.func(**kwargs)

流程完全一样,唯一区别:s02 查字典直接拿到函数;这里拿到装着函数的 Tool,再通过 t.call 调用。

完整时间线:

复制代码
程序启动(还没和模型说话):
  Python 读到 @tool("...") def grep(...)
    → tool("...") 返回 deco
    → deco(grep)
        → _infer_schema(grep) 自动生成 schema
        → Tool(name="grep", ..., func=grep, parameters=schema)
        → registry.register(...) 放进 _tools 字典
        → return grep

发请求给模型:
  registry.to_openai_tools() → 遍历 _tools,每个 to_openai_schema() → 组成列表

模型要调用工具:
  name="grep", arguments='{"path": "a.py", "text": "def"}'
    → registry.get("grep") 拿到 Tool
    → json.loads(arguments) 得到字典
    → Tool.call(**args) → grep(path="a.py", text="def")

5.8 一句话串起来

s02:我写函数,我写 schema,我写分发表。

registry_agent:我写函数并贴上 @tool,装饰器替我生成 schema、替我填进分发表。

函数签名本身就已经包含了 schema 需要的信息,与其手写一遍,不如让代码自己读出来。

真实框架(OpenAI Agents SDK、LangChain、MCP Python SDK)基本都用这个套路。


六、OpenAI vs Anthropic 格式对照

s02(Anthropic) registry_agent(OpenAI)
工具定义 {"name", "description", "input_schema"} 外面多包一层 {"type": "function", "function": {...}},叫 parameters
系统提示 单独的 system= 参数 messages 第一条,role: "system"
模型的工具调用 block.input 已经是字典 tc.function.arguments 是 JSON 字符串 ,要 json.loads
工具结果的角色 role: "user" role: "tool"
多个工具结果 合并成一条 user 消息 每个结果单独一条 tool 消息
对应关系 tool_use_id tool_call_id

看代码里 append 的位置就能分清:

python 复制代码
# OpenAI:append 在 for 循环里面,循环几次加几条
for tc in msg.tool_calls:
    self.messages.append({"role": "tool", ...})

# s02:append 在 for 循环外面,只加一条
for block in tool_calls:
    results.append(...)
messages.append({"role": "user", "content": results})

七、我容易混淆的地方汇总

  1. command 不是什么特殊关键字,只是 schema 里自己起的参数名,和函数参数名对上就行。
  2. TOOL_HANDLERS 存的是函数本身 (run_bash 无括号),不是调用结果。
  3. ** 在两个位置作用相反 :def f(**kwargs) 是打包,f(**d) 是解包。
  4. 参数错误时函数内部的 try 接不住 ,因为报错发生在调用那一刻、函数体执行之前,必须在 agent_loop 分发处包 try。
  5. 截断要留提示,否则模型以为看到的是全部。
  6. description 决定模型用不用这个工具、怎么用:写得模糊模型会改用 bash;写得和实际行为不一致等于骗模型。
  7. 数消息条数时,模型这一轮的回复本身也算一条 ,而且多个工具调用装在同一条 assistant 消息里。
  8. registry_agent 里看不到 schema 和分发表 ,因为它们是运行时自动生成在 registry._tools 里的。
  9. 装饰器的注册发生在程序启动、读到函数定义时,不是模型调用时。
  10. 删掉 return func 模型照样能调用,坏的只是代码里直接调用这个函数。
  11. register 重名用 raise 而 _execute 出错返回字符串:前者是程序员的错(启动时暴露),后者是模型的错(交给模型改)。
  12. OpenAI 回填 role 是 tool、每个结果一条;Anthropic 是 user、合并一条。
  13. agent 能改自己的源码 :safe_path 保护工作目录,但它自己就在工作目录里。

八、自测题与答案(我的回答 + 修正)

s02 毕业四问

Q1:模型调了 3 次工具才回答,history 里新增了几条消息?

我的答案:7 条,调用工具 6 条,输出结果 1 条。✅

说明:老师给的参考答案括号里写"1 条用户问题 + 3 对调用/结果 + 1 条最终回答",加起来其实是 8 条,是老师算错了,我的拆法更准确。

来源 条数
用户问题(在 __main__ 里 append) 1
3 次工具调用(assistant) 3
3 次工具结果(user) 3
最终回答(assistant) 1
  • agent_loop 新增:7 条 ;算上用户问题:8 条
  • 例外:一轮里同时 调用 3 个工具,只占 1 条 assistant + 1 条 user,agent_loop 只新增 3 条

Q2:为什么工具结果要放在 "role": "user" 里?

我的答案:对于 LLM API,用户的输入和工具调用返回的结果都算外部输入。✅

补充:API 只有 user 和 assistant 两个角色,凡是不是模型生成的内容都只能从 user 进来 ,靠 tool_use_id 区分是哪次调用的结果。

Q3:如果模型陷入循环一直调工具,代码哪里需要改?

我的答案:限制最大步长,改 agent_loop。✅

python 复制代码
MAX_STEPS = 20

def agent_loop(messages: list):
    for step in range(MAX_STEPS):          # while True 改成有上限的 for
        response = client.messages.create(...)
        messages.append({"role": "assistant", "content": response.content})
        tool_calls = [b for b in response.content if b.type == "tool_use"]
        if not tool_calls:
            return
        # ... 执行工具、append results ...
    print(f"\033[31m已达到最大步数 {MAX_STEPS},强制停止\033[0m")

Q4:删掉 r.stderr,模型遇到 sed 报错时会看到什么?

我的答案:'sed' is not recognized... 是从 stderr 出来的。🟡(只答了第一步)

完整推导:

  1. sed 不存在,报错写进 stderr
  2. stdout 是空的
  3. 删掉 r.stderr 后 out 是空字符串
  4. 返回 "(no output)"

更危险 :sed -i 成功时本来就不输出,模型会以为"执行成功了",继续往下走,最后说"已改好",文件其实没动。

情况 模型看到 模型的判断
保留 stderr 'sed' is not recognized... 失败了,换方法
删掉 stderr (no output) 成功了,继续 ❌

抛异常是让程序崩掉,删掉 stderr 是让错误悄悄消失,后者更隐蔽。进一步可以加退出码:

python 复制代码
out = (r.stdout + r.stderr).strip()
if r.returncode != 0:
    out = f"[exit code {r.returncode}]\n{out}"
return out[:50000] if out else "(no output)"

registry_agent 毕业四问

Q5:模型一轮调用了 2 个工具,OpenAI 版 messages 新增几条?Anthropic 版呢?

我的答案:OpenAI 2 条,Anthropic 4 条。❌

修正:OpenAI 3 条,Anthropic 2 条。 关键是模型这一轮的回复本身也算一条,且两个调用装在同一条 assistant 消息里。

python 复制代码
# OpenAI:3 条
{"role": "assistant", "tool_calls": [调用1, 调用2]}
{"role": "tool", "tool_call_id": id1, "content": 结果1}
{"role": "tool", "tool_call_id": id2, "content": 结果2}

# Anthropic:2 条
{"role": "assistant", "content": [调用1, 调用2]}
{"role": "user", "content": [结果1, 结果2]}

区别只在结果那一侧,调用那一侧两家都是一条。(D1 笔记 Q3 也错在这里,同一个点错了两次,重点记忆。)

Q6:定义两个都叫 read 的工具,程序会怎样?什么时候发生?

我的答案:启动时报错,if tool.name in self._tools: raise ValueError(...)。✅

补充:"启动时"是关键:第二个 @tool 被 Python 读到时就 raise,还没开始对话程序就停了。

Q7:去掉 deco 里的 return func,模型还能正常调用吗?

我的答案:deco 默认返回 None,calculator 就变成了 None。🟡(只答了一半)

修正:题目问模型能不能调用,答案是能 。register 在 return 之前已经把原函数存进 Tool.func,模型调用走 registry,不经过外面变成 None 的变量。坏的只是代码里直接调用 calculator(...)(见 5.6)。

Q8:模型传来的 arguments 是 '{"path": "a.py"'(少右括号),会发生什么?

我的答案:_execute 会返回错误提示让模型改正。✅

补充:json.loads 抛 JSONDecodeError,被捕获后返回 错误:参数不是合法 JSON:...。在执行工具之前就拦下了,工具函数没被调用。


相关推荐
徐小夕@趣谈前端2 小时前
老旧Word格式全支持!JitWord协同AI文档4.1,让Word文档无缝迁移到Web端
前端·人工智能·word
螺丝钉tina2 小时前
[Playwright][01] 从 Puppeteer 到 Playwright:为什么选择 Playwright + TypeScript?
前端·javascript·typescript
Sailing2 小时前
🚀🚀【封闭结束6】Agent 会话服务与鉴权上下文
前端·人工智能·agent
IT_陈寒2 小时前
Java项目实战经验分享:一次由JPA懒加载引发的生产事故
前端·人工智能·后端
环境栈笔记2 小时前
浏览器多开同步怎么做?先分清设置、登录态和窗口操作
前端·人工智能·后端·自动化
是立不是利2 小时前
CSS 的层叠与优先级——为什么我的样式不生效
前端·css
开开心心就好2 小时前
电脑语音实时转文字工具,会议记录很省事
运维·服务器·前端·c#·ocr·perl·1024程序员节
aixingpan2 小时前
aixingpan.cn API开发文档:api_docs_onechart_marks_secondary2接口指南
前端·php
唔663 小时前
在透镜圆正中加了一个描边空心字
前端·javascript