跳到主要内容

手写 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

分成两层, 执行顺序是:

  1. tool("list_dir", "...", {...}) 先执行, 把名称, 描述和参数记在闭包里, 返回 decorator
  2. @ 把下面定义好的函数交给 decorator
  3. decorator 往 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_dirpath目录名补 /, 结果截断
read_filepath不确认, 不截断
write_filepath, content执行前确认, 自动建父目录
run_shellcommand执行前确认, 30 秒超时, 输出分两路
fetch_urlurl校验协议, 按字节截断, 15 秒超时
get_weathercity返回字典, 固定模拟数据
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 = 20MAX_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.pytools.py 加 agent.py
注册结构TOOLS 列表, TOOL_NAME 字典TOOLS 字典, 值里同时存说明和函数
参数简写在装饰器里展开单独的 _params() 函数
写文件确认有无
执行器名excuteexecute
返回值处理执行器返回原值, 循环里 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 一致

参考资料​

更新记录: 2026-11-02 创建, 按 code/02-tools/tools.py 当前实现整理, 补充装饰器注册, 七个工具, 执行器, 主循环对照与调试位置; 示例输出为示意, 不代表本次真实运行记录.