手写 Agent 第 2 课:工具调用
第 2 课: 工具调用
第 1 课的三个工具是写死的. 本课把它改成注册表加统一执行器: 新增工具只写一个函数, 主循环一行不改; 工具报错不再让程序崩, 而是变成模型能读到的一条结果.
代码见 code/02-tools/tools.py, 参考教程见 NamiAgent 第 2 课, 上一课见 最小循环.
📌 本课代码是单个文件, 工具定义和主循环都在
tools.py里. 参考教程拆成tools.py和agent.py两个文件, 差异见文末.下文代码块均为节选, 省略了函数注释和部分类型标注; 字符串和标点与源文件一致, 完整代码以
code/02-tools/tools.py为准.
第 1 课留下的三个问题
| 问题 | 第 1 课的做法 | 后果 |
|---|---|---|
| 加工具要改三处 | 写函数, 写说明, 登记 TOOL_MAP | 漏一处就跑不通 |
| 说明和函数分开写 | def_tools(...) 和 def 各写一遍 | 改了函数容易忘改说明 |
| 错误处理堆在循环里 | 循环里一个 try/except 包住所有工具 | 工具越多分支越乱 |
本课用两个东西解决: 装饰器让"说明跟着函数走", 执行器让"错误统一变成结果".
一个工具由什么组成
| 要素 | 作用 | 给谁看 |
|---|---|---|
name | 工具名称, 模型回传时用它指认 | 模型, 代码 |
description | 这个工具做什么, 什么时候该用 | 模型 |
parameters | 参数的 JSON Schema | 模型 |
| 函数本身 | 真正执行的 Python 代码 | 代码 |
前三项是发给模型的说明书, 第四项是本地实现. 模型看不到你的代码, 只看到前三项.
description 本质上是提示词, 它决定模型选不选这个工具, 以及传什么样的参数. 同一段代码, 说明写成"执行命令"和写成"在当前工作目录执行 shell 命令, 返回退出码和输出", 模型的表现不一样.
装饰器与注册表
@tool 做了什么
def tool(name, description, parameters):
"""创建用于注册模型工具说明的装饰器."""
def decorator(func):
TOOLS.append({
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": {k: {"type": "string", "description": v} for k, v in parameters.items()},
"required": list(parameters.keys()),
},
},
})
TOOL_NAME[name] = func
return func
return decorator
分成两层, 执行顺序是:
tool("list_dir", "...", {...})先执行, 把名称, 描述和参数记在闭包里, 返回decorator@把下面定义好的函数交给decoratordecorator往TOOLS追加一条说明, 往TOOL_NAME登记函数, 然后把原函数原样返回
@tool(name="read_file", description="读取本地文件内容", parameters={"path": "文件位置"})
def read_file(path: str) -> str:
...
装饰之后 read_file 还是原来那个函数, 可以直接调用, 也可以按名字查出来调用.
两份数据
| 变量 | 内容 | 给谁用 |
|---|---|---|
TOOLS | 列表, 每条是模型的工具说明 | 每轮请求的 tools 参数 |
TOOL_NAME | 字典, 工具名到函数的映射 | 执行工具时按名字找函数 |
第 1 课也有这两份数据, 叫 TOOLS 和 TOOL_MAP. 区别只在名字和填空时机: 第 1 课是手写列表, 本课是装饰器在导入时自动追加.
注册发生在什么时候
@tool(...) 在 模块导入时 就执行完, 不是调用工具时. 所以脚本一启动, TOOLS 里已经有 7 条说明, TOOL_NAME 里已经有 7 个函数.
顺序就是代码里函数定义的顺序: list_dir, write_file, run_shell, fetch_url, read_file, get_weather, get_current_time. 这个顺序只是说明书的排列顺序, 不影响模型选谁.
类型标注保住了什么
ToolFunction = TypeVar("ToolFunction", bound=Callable[..., object])
def tool(name: str, description: str, parameters: dict[str, str]) -> Callable[[ToolFunction], ToolFunction]:
装饰器如果不标注, 类型检查器会认为被装饰的函数变成了"任意可调用对象", 函数名和参数都对不上. 用 TypeVar 绑定后, 装饰前后签名一致, 编辑器还能提示 read_file(path=...).
参数简写的代价
parameters 收的是"参数名到说明"的字典, 装饰器统一展开成:
{
"path": {
"type": "string",
"description": "文件位置"
}
}
这带来两个限制:
- 所有参数都是
string, 写不了integer,enum, 数组 - 所有参数都进
required, 写不了可选参数
本课 7 个工具的参数刚好都是必填字符串, 所以够用. 遇到 timeout 这类有默认值的参数, 就得自己写完整 schema, 不能再用这个简写.
无参数工具传空字典, 展开后 required 是空列表:
@tool(name="get_current_time", description="获取本机当前日期和时间(使用系统本地时区)", parameters={})
七个工具
| 工具 | 参数 | 特殊处理 |
|---|---|---|
list_dir | path | 目录名补 /, 结果截断 |
read_file | path | 不确认, 不截断 |
write_file | path, content | 执行前确认, 自动建父目录 |
run_shell | command | 执行前确认, 30 秒超时, 输出分两路 |
fetch_url | url | 校验协议, 按字节截断, 15 秒超时 |
get_weather | city | 返回字典, 固定模拟数据 |
get_current_time | 无 | 无参数, 返回本地时间字符串 |
确认放在工具里
write_file 和 run_shell 都先 input() 问一句 y/n:
confirmation = input(
f"\n[需要确认] 写入文件:{path}({len(content)} 字符,已有文件会被覆盖)\n"
"允许?(y/n) "
)
if confirmation.strip().lower() != "y":
return "用户拒绝写入文件"
放在工具里而不是循环里, 原因是: 循环只认"工具名加参数", 它不知道哪个工具危险. 危险与否是工具自己的属性, 写在工具里才对.
用户拒绝时返回的字符串会作为 tool 消息进历史, 模型下一轮能看到"用户拒绝了", 不会死循环重试.
截断分三种口径
def truncate_output(text: str, limit: int = MAX_OUTPUT) -> str:
if len(text) <= limit:
return text
return f"{text[:limit]}\n...(已截断,原文共 {len(text)} 个字符)"
- 目录列表: 按字符数截断, 上限 8000
- 命令: 标准输出和标准错误各 4000, 合计不超过 8000
- 网页: 先只读 8001 字节, 超过 8000 就截断并说明
- 文件读取: 不截断,
read_file直接返回全文
网页用字节而不是字符, 是因为此时还没解码, 不知道一个字符占几个字节.
工具函数不吞异常
fetch_url 遇到不合法的地址直接抛:
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("只支持完整的 HTTP 或 HTTPS 地址")
这不是疏忽. 工具只管干活, 出错就抛; 统一由下一节的执行器接住, 转成模型能读的文字. 这样每个工具里都不用复制一遍 try/except.
统一执行器 excute
def excute(tool_name: str, tool_args: dict[str, str]) -> str | dict[str, str | int]:
if tool_name not in TOOL_NAME:
return f"工具 {tool_name} 不存在"
tool_func = TOOL_NAME[tool_name]
try:
res = tool_func(**tool_args)
return res
except Exception as e:
return f"调用工具 {tool_name} 时发生错误: {str(e)[:4000]}"
错误也是一种结果
这是本课最关键的设计. 工具报错时, 程序不崩, 而是把报错原文交回模型:
调用工具 read_file 时发生错误: [Errno 2] No such file or directory: 'notes.txt'
模型看到这句, 通常会先调 list_dir 看看目录里有什么, 再读正确的文件. 这个"看报错, 自己改"的过程, 就是 Agent 和普通脚本的区别.
如果这里直接抛异常, 整个程序退出, 模型连补救的机会都没有.
工具名不存在也走这条路
模型偶尔会编一个不存在的工具名. TOOL_NAME 里查不到, 直接返回一句"工具 xxx 不存在", 不用抛异常.
💡 这里的提示只写了工具名, 没有列出可用工具. 参考实现会补一句"可用工具: ...", 模型改了名字后更容易一次改对. 要加的话, 在返回值里拼上
", ".join(TOOL_NAME)即可.
返回值可能是字典
get_weather 返回的是字典, 不是字符串:
return {"city": city, "temperature": 25, "description": "晴天"}
而 tool 消息的 content 必须是字符串, 所以循环里回填前要转:
messages.append({"role": "tool", "tool_call_id": call.id, "content": str(tool_res)})
不转的话, 下一次请求会因字段类型不对而失败.
📌 函数名是
excute, 是execute的拼写笔误. 当前代码按这个名字调用, 本文也照原样记录. 改名要同时改定义和调用处.
主循环 run_task
与第 1 课逐块对照
| 部分 | 第 1 课 | 第 2 课 |
|---|---|---|
| 工具来源 | 三个全局函数 | 装饰器自动注册 |
| 工具说明 | 手写 TOOLS 列表 | @tool 生成 |
| 执行 | 循环里 TOOL_MAP[name](**args) | 循环里 excute(name, args) |
| 错误处理 | 循环里的 try/except | 执行器内部 |
| 轮数上限 | MAX_STEPS = 20 | MAX_STEPS = 10 |
| 入口 | 写死一个 task | 写死一个 run_task(...) 调用 |
循环本体
for step in range(1, MAX_STEPS + 1):
res = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
message = res.choices[0].message
messages.append(message.model_dump(exclude_none=True))
tool_calls = message.tool_calls
if not tool_calls:
print(f'第 {step} 步模型没有调用工具,任务完成,输出结果:{res.choices[0].message.content}')
return
for call in tool_calls:
tool_name = call.function.name
tool_args = json.loads(call.function.arguments)
tool_res = excute(tool_name, tool_args)
messages.append({"role": "tool", "tool_call_id": call.id, "content": str(tool_res)})
和第 1 课相比, 循环里已经没有任何具体工具的名字. 它只做四件事: 问模型, 记助手消息, 分发工具调用, 回填结果.
三处没变的地方
- 先追加助手消息, 再处理工具结果. 顺序反了下一次请求会报缺少对应调用
tool_call_id用call.id, 不是工具名- 一轮可能有多个
tool_calls, 每个都要回一条tool消息
这三条是协议要求, 不因工具变多而变.
注释掉的那行
# print(f'第 {step} 步模型工具调用请求:{tool_calls}\n')
调试时用过的打印, 已经注释掉. 想看原始结构时取消注释即可.
运行与边界
跑起来
当前入口写死了一个任务:
run_task("请列出当前目录,创建 notes/hello.txt 写入 Hello, Agent!,然后读取并总结。")
在 playground 目录里跑, 预期过程:
=== 第 1 步 ===
调用工具名称:list_dir,参数:{'path': '.'}
=== 第 2 步 ===
调用工具名称:write_file,参数:{'path': 'notes/hello.txt', 'content': 'Hello, Agent!'}
[需要确认] 写入文件:notes/hello.txt(13 字符,已有文件会被覆盖)
允许?(y/n) y
=== 第 3 步 ===
调用工具名称:read_file,参数:{'path': 'notes/hello.txt'}
=== 第 4 步 ===
第 4 步模型没有调用工具,任务完成,输出结果:已创建 notes/hello.txt,内容为 Hello, Agent!
模型可能少走一步, 多走一步, 或者把读取和创建的顺序换掉. 这都正常, 只要最后的结果对.
三个观察点:
- 每步打印的工具名和参数, 是不是你预期的路径
- 写文件和执行命令时, 确认提示出现了没有
- 最后一条
assistant消息里,tool_calls是不是None
这一课的边界
read_file,list_dir,fetch_url不确认, 模型可以随便读, 随便访问任意 http 地址- 相对路径以 Python 进程的当前工作目录为准, 在
code目录里跑会把文件写进code - 停止条件仍是"模型不再请求工具", 不代表任务验证成功
MAX_STEPS是 10, 一轮里有多个工具也只占一轮- 模型请求本身失败 (网络, 鉴权) 不被
excute捕获, 会直接中断
最后一条尤其要注意: 第 1 课循环里的 try/except 也是只包工具, 不包 create(). 本课把工具的异常搬到执行器里, 这个边界没有变.
与参考实现的差异
| 方面 | 自己的实现 | NamiAgent 第 2 课 |
|---|---|---|
| 文件拆分 | 单文件 tools.py | tools.py 加 agent.py |
| 注册结构 | TOOLS 列表, TOOL_NAME 字典 | TOOLS 字典, 值里同时存说明和函数 |
| 参数简写 | 在装饰器里展开 | 单独的 _params() 函数 |
| 写文件确认 | 有 | 无 |
| 执行器名 | excute | execute |
| 返回值处理 | 执行器返回原值, 循环里 str() | 工具函数内 str() |
| 主循环入口 | 写死一个任务 | 交互式 input() 多轮对话 |
| 工具数量 | 7 个 | 5 个 |
剩下的部分一致: 装饰器注册, 说明与函数同步, 错误当结果, 输出截断, 轮数上限.
参考实现把 messages 提到循环外, 支持连续多轮对话; 本课每次 run_task() 都从新历史开始. 下一课改权限时再决定要不要保留会话.
断点调试
看注册结果
在第一个 @tool(...) 之后任意位置打断点, 或在文件末尾加一行:
print(json.dumps(TOOLS, indent=2, ensure_ascii=False))
能直接检查七条说明的 name 和 properties 有没有写错. 这个打印只用于调试, 不要留在循环里.
看一次工具调用
在 tool_args = json.loads(call.function.arguments) 下一行打断点, 展开:
call
id
function
name
arguments ← 字符串
tool_args ← 解析后的字典
重点看 tool_args 的键能不能对上函数参数名, 对不上会在 tool_func(**tool_args) 处抛 TypeError.
看回填的消息
在 messages.append({"role": "tool", ...}) 下一行打断点, 检查:
content是字符串, 不是字典tool_call_id和刚才call.id一致
参考资料
- DeepSeek Chat Completions API:
tools参数和tool_calls返回字段. - OpenAI Function Calling 指南: 工具说明的结构与调用流程.
- JSON Schema 入门:
parameters里各关键字的含义. - 参考实现: NamiAgent 的
lesson-02/tools.py
更新记录: 2026-11-02 创建, 按 code/02-tools/tools.py 当前实现整理, 补充装饰器注册, 七个工具, 执行器, 主循环对照与调试位置; 示例输出为示意, 不代表本次真实运行记录.