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 万行):
- 撑爆上下文:API 直接报错,对话进行不下去
- 烧钱、变慢:按 token 计费,之后每一轮都要重发
- 模型变笨:信息太多反而找不到重点
| 位置 | 截断方式 | 防什么 |
|---|---|---|
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) cat10 万行会撑爆上下文,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 属性名和参数名一致。
可以改进的三点:
- 没有行号:模型 grep 到之后通常要去读或改附近代码,不知道第几行
- 只能搜一个文件:最常见的用法是"整个项目里哪里用了 X"
- 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,它改了自己的源码。 我觉得很荒谬,讨论后的结论:
- 它不知道自己在改"自己" :
WORKDIR = Path.cwd(),我在s02_tool_use里启动,code.py恰好在工作目录里,对模型来说只是一个普通文件 - 改了不会立刻生效:Python 启动时已把代码读进内存,新代码要重启才生效。是"给明天的自己改说明书"
- 真正的安全问题 :
safe_path保护的是工作目录,而safe_path本身就写在工作目录里。 模型可以用edit_file把dangerous黑名单或safe_path的检查删掉,下次启动防护就没了。就像把保险柜钥匙放在保险柜里 - 解决 :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 改三处一对比,区别很明显。
两个问题:
- description 说能搜目录,代码只能搜文件 。比代码 bug 更隐蔽:模型完全信任 description,看到"文件或目录"就会传目录然后失败。description 和实际行为不一致,等于骗了模型。
- 截断时没有提示 :搜到第 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= s02TOOLS里那一项(②)
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})
七、我容易混淆的地方汇总
command不是什么特殊关键字,只是 schema 里自己起的参数名,和函数参数名对上就行。TOOL_HANDLERS存的是函数本身 (run_bash无括号),不是调用结果。**在两个位置作用相反 :def f(**kwargs)是打包,f(**d)是解包。- 参数错误时函数内部的
try接不住 ,因为报错发生在调用那一刻、函数体执行之前,必须在agent_loop分发处包try。 - 截断要留提示,否则模型以为看到的是全部。
- description 决定模型用不用这个工具、怎么用:写得模糊模型会改用 bash;写得和实际行为不一致等于骗模型。
- 数消息条数时,模型这一轮的回复本身也算一条 ,而且多个工具调用装在同一条 assistant 消息里。
- registry_agent 里看不到 schema 和分发表 ,因为它们是运行时自动生成在
registry._tools里的。 - 装饰器的注册发生在程序启动、读到函数定义时,不是模型调用时。
- 删掉
return func模型照样能调用,坏的只是代码里直接调用这个函数。 register重名用raise而_execute出错返回字符串:前者是程序员的错(启动时暴露),后者是模型的错(交给模型改)。- OpenAI 回填 role 是
tool、每个结果一条;Anthropic 是user、合并一条。 - 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 出来的。🟡(只答了第一步)
完整推导:
sed不存在,报错写进 stderr- stdout 是空的
- 删掉
r.stderr后out是空字符串 - 返回
"(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:...。在执行工具之前就拦下了,工具函数没被调用。