第 2 章 · Part 1
工具系统
本章你将回答:
- 工具的 JSON Schema 能不能从函数签名自动生成?
- 工具报错时,循环应该崩溃还是把错误交给模型?
- 工具和技能(skills)是什么关系?
上一章留下的伤口
第 1 章结尾留了个挑战:给 agent 再加一个 now() 工具,回答”现在距离 2027 年元旦还有多少天”。如果你真动手了,就会发现”加一个工具”远不止”写个函数”这么轻巧。你至少要同时改三处地方:在 TOOLS 列表里手抄一段 JSON schema(名字、description、parameters、required 一个字段都不能错,还得跟函数签名严丝合缝地对齐);在循环里把原本写死的 result = calculator(**args) 改成按 call.function.name 分发的 if/else;最后才是函数本身。三件事里,真正有价值的只有第三件。
这套手工活的麻烦不在”累”,在”容易错且不可扩展”。schema 是手抄的,它和函数是两份各自独立的”真相”——你哪天改了函数签名却忘了同步 schema,模型就会照着一份过时的说明书传参,而这种错往往不报错、只是行为诡异,最难排查。分发的 if/else 也一样:每加一个工具就多一段 elif,十个工具就是十段 elif 加十段手抄 schema,任何一个笔误都得你拿肉眼去逮。工具越多,这堆样板代码就越失控。
追到根上,病因是”定义一个函数”和”把它暴露给模型”被拆成了两件彼此不同步的事。本章要做的,就是把这两件事重新捏回成一件——你只管写一个规范的 Python 函数,schema 和分发就自动都有了。
停下来想想如果由你来设计:怎样让任何一个普通 Python 函数,一行代码就能变成 agent 工具?模型需要知道这个函数的哪些信息?
先自己想一想,再展开参考思路
模型需要三样:名称(调用时引用)、描述(何时该用它)、参数 schema(怎么传参)。
而这三样在一个写得规范的 Python 函数里本来就有:函数名、docstring、签名+类型注解。所以答案是一个装饰器 + 反射(inspect)——这正是 smolagents、LangChain、Strands 等一众框架 @tool 装饰器的原理。
工具注册表
"""工具注册表:普通 Python 函数 + 装饰器 = agent 工具,schema 自动生成。"""
import inspect
import json
from typing import Callable, get_type_hints
_PY_TO_JSON = {str: "string", int: "integer", float: "number", bool: "boolean"}
class ToolRegistry:
def __init__(self):
self._tools: dict[str, Callable] = {}
def tool(self, fn: Callable) -> Callable:
"""装饰器:注册函数,schema 从签名 + 类型注解 + docstring 自动生成。"""
self._tools[fn.__name__] = fn
return fn
def schemas(self) -> list[dict]:
out = []
for name, fn in self._tools.items():
hints = get_type_hints(fn)
props, required = {}, []
for p in inspect.signature(fn).parameters.values():
props[p.name] = {"type": _PY_TO_JSON.get(hints.get(p.name, str), "string")}
if p.default is inspect.Parameter.empty:
required.append(p.name)
out.append({"type": "function", "function": {
"name": name,
"description": inspect.getdoc(fn) or "",
"parameters": {"type": "object", "properties": props, "required": required},
}})
return out
def call(self, name: str, arguments: str) -> str:
"""执行一次工具调用。任何异常都转成字符串返回——绝不让循环崩掉。"""
if name not in self._tools:
return f"错误:不存在名为 {name} 的工具。可用工具: {list(self._tools)}"
try:
return str(self._tools[name](**json.loads(arguments)))
except Exception as e:
return f"工具执行出错: {type(e).__name__}: {e}"
registry = ToolRegistry()
@registry.tool
def calculator(expression: str) -> str:
"""计算一个 Python 数学表达式,如 '3 * (4 + 5)' 或 'math.sqrt(2)'。"""
import math
return str(eval(expression, {"__builtins__": {}}, {"math": math})) # 演示用
@registry.tool
def now() -> str:
"""返回当前日期和时间,ISO 格式。"""
import datetime
return datetime.datetime.now().isoformat(sep=" ", timespec="seconds")
@registry.tool
def list_files(directory: str) -> str:
"""列出目录下的文件名。"""
import os
return "\n".join(sorted(os.listdir(directory)))
@registry.tool
def read_file(path: str) -> str:
"""读取一个文本文件的内容(最多 4000 字符)。"""
with open(path, encoding="utf-8") as f:
return f.read()[:4000]
先看那个 @registry.tool 装饰器,它其实简单到有点反高潮:self._tools[fn.__name__] = fn,把函数按名字塞进一个字典,然后原样 return fn。注意它并不包装、不改写函数——所以 calculator("1+1") 这样直接调用照样能用。注册发生在 import 时(装饰器在模块加载那一刻执行),等 agent 真正跑起来,registry 里早已躺好了 calculator、now、list_files、read_file 四个工具。加第五个?在函数上方贴一行 @registry.tool,别的什么都不用碰。
真正的魔法在 schemas()。它遍历 self._tools,对每个函数用 inspect.signature 取参数列表、get_type_hints 取类型注解、inspect.getdoc 取 docstring——第 1 章里你手抄进 TOOLS 的那三样信息,现在全从函数自身反射出来。参数类型经 _PY_TO_JSON 映射成 JSON Schema 类型(str→"string"、int→"integer"…,认不出的兜底成 "string");有没有默认值决定它进不进 required(p.default is inspect.Parameter.empty 为真,就是必填参数)。产出的 dict 结构和你上一章手写的一模一样,区别只在于:现在再没有人会写错,因为函数签名成了唯一的真相来源。这里也顺带回答了本章第一个问题——docstring 不是给人看的注释,它会原样变成模型读到的 description,写得含糊模型就用得糊涂;至于返回值类型注解(-> str),它压根不进 schema,因为模型只关心”怎么调用”,返回什么是运行时才回灌的观察。
分发同样被收进了一处。call(name, arguments) 先查 name in self._tools——查不到并不崩溃,而是返回一句”不存在名为……的工具”外带一份可用工具清单,让模型看到后自己改用对的名字。查到了,就 json.loads(arguments) 解出参数、用 ** 展开调用、再 str() 转成字符串。整个执行裹在一层 try/except 里:任何异常都被 f"工具执行出错: {type(e).__name__}: {e}" 接住,当作结果返回。这正是第 1 章 calculator 里那个 except 的升级版——把它从单个函数里提上来,一处 try/except 就守住了所有工具,而不用每个函数各写一遍。原则没变、且更彻底:错误是有信息量的观察,回灌给模型它就能换参数、换思路自我修正,绝不让一次工具报错掀翻整个循环。
循环瘦身了
"""最小 Agent + 工具注册表:循环不再关心具体有哪些工具。
运行:python agent.py "labs 目录下有几个章节目录?现在几点了?"
"""
import os
import sys
from openai import OpenAI
from tools import registry
client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("MODEL", "gpt-4o-mini")
MAX_STEPS = 10
def run(task: str) -> str:
messages = [
{"role": "system", "content": "你是一个会用工具的助手。用工具获取事实,得到最终答案后直接回答。"},
{"role": "user", "content": task},
]
for step in range(1, MAX_STEPS + 1):
msg = client.chat.completions.create(model=MODEL, messages=messages, tools=registry.schemas()).choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for call in msg.tool_calls: # 模型可能一次请求多个工具调用(并行工具调用)
result = registry.call(call.function.name, call.function.arguments)
print(f" [step {step}] {call.function.name}({call.function.arguments}) -> {str(result)[:80]}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "(超出最大步数,强制停止)"
if __name__ == "__main__":
print("最终答案:", run(sys.argv[1] if len(sys.argv) > 1 else "现在几点?顺便算一下 365*24。"))
打开这段 agent.py,高亮的行就是它相对第 1 章的全部差异——而最值得玩味的,是差异之”少”。开头的 import json、import math 不见了(数学计算是 calculator 工具内部的事,循环本不该操心),换上的只有一行 from tools import registry。中段那一大块 TOOLS schema 加 calculator 函数定义——上一章占了约二十行——整个消失,搬进了 tools.py。循环把”有哪些工具、各自怎么实现”这些细节,全部甩给了注册表。
循环体的高亮乍看不少,但逐行对着读你会发现,大半是”措辞”在变、不是”逻辑”在变。docstring 和示例任务换了说法;system prompt 从”需要计算时调用 calculator”泛化成”用工具获取事实”——提示词里从此不点名任何工具,这本身就是解耦的一部分;print 那行不再写死 calculator,改打通用的 call.function.name,结果还加了 [:80] 截断(read_file 这类工具的输出可能很长,别刷屏);剩下几处高亮,其实只是删掉了第 1 章的教学注释。注释变少本身也是收益:像”错误也要回灌”这样当初需要旁白解释的意图,如今沉淀进了 tools.py 的结构里,代码自己会说话。
真正改变循环逻辑的只有两处。一是 create(...) 里的 tools= 参数,从写死的 TOOLS 换成了 registry.schemas()——工具清单现在是每次动态生成的。二是分发:上一章那两行 args = json.loads(...); result = calculator(**args) 缩成了一行 registry.call(call.function.name, call.function.arguments)。关键在于,这一行里再也没有任何具体工具的名字。你往 tools.py 加第五个、第十个工具,这段循环一个字都不用改——它只负责”把模型点名的那个工具跑一遍”,至于是哪个、怎么跑,全交给注册表。这就是”循环瘦身”的实质:控制流和工具集彻底解耦了。
还有个细节藏在 for call in msg.tool_calls 里值得停一停。模型在一条 assistant 消息里可以一次点名多个工具——比如同时要”现在几点”和”算一下 365*24”——这就是并行工具调用。协议层天然支持它,因为 tool_calls 本来就是个列表。我们这里用 for 逐个执行,每跑完一个就追加一条 role: "tool" 消息,并带上它自己的 tool_call_id。这个 id 是回灌的命脉:模型靠它把每份结果对回当初发起的那次调用;少一条或对错 id,服务端多半直接报错。要说清楚的是,“并行”指的是模型一次请求多个,执行端像我们这样串行跑完全合法(AgentScope 才真的把它们并发执行)——够用,且简单。
停下来想想工具越多越好吗?如果给模型挂上 100 个工具会发生什么?
先自己想一想,再展开参考思路
三个代价:①每个 schema 都进上下文,token 膨胀;②选项越多误选率越高;③很多工具还需要配套的使用知识(先调 A 再调 B)。 所以规模化的答案不是”更多工具”,而是分组、按需加载、把工具+知识+流程打包——这就是”技能(skills)“的动机,见 技能与插件。
从工具到技能
上面的思考题已经点破:工具无限往上堆,会 token 膨胀、会误选率飙升,很多工具还得配一套”什么时候用、按什么顺序用”的知识。规模化的答案不是”更多工具”,而是换一个封装粒度——把”一组相关工具 + 使用它们的知识 + 推荐的流程”打成一个包,需要时才展开加载。这个介于单个函数和整个 agent 之间的中等粒度单元,就是技能(skill)。
如今一个相当有代表性的形态是 Claude Code 式的 skill:一个带 YAML frontmatter 的 SKILL.md,frontmatter 里用一句话讲清”这技能干什么、何时该用”,正文写详细流程,附带的脚本与参考文档按需再读。模型平时只看到那句摘要(省 token),判断”该用了”才展开全文——这叫渐进式披露。这套约定几乎成了事实标准,被 OpenClaw、Hive、AgentScope 等一大批框架兼容。OpenClaw 还更进一步,用 plugin / extension 把技能、生命周期钩子、主题打成可安装可卸载的包,让 agent 的能力像装 App 一样扩展。
工具和技能不是取代关系,而是粒度关系。单个函数解决的是”让 agent 多一个动作”,技能解决的是”让 agent 多一整套本领”——两者回答的是同一个问题:怎么扩展 agent 能做什么。这条从工具到技能的谱系,技能与插件里有更完整的横向对比。
检查你的理解
自动生成工具 schema 时,信息来源 不包括 ?
查看解析
schema 在注册时静态生成,函数还没运行。返回值类型甚至不进 schema——模型只关心怎么调用,结果是运行时才回灌的。
工具执行抛了异常,最佳实践是?
查看解析
错误是有信息量的观察。回灌后模型能换参数、换思路自我修正;静默忽略让模型误以为成功;无限重试可能死循环。
关于并行工具调用,下列说法正确的有(多选)?
查看解析
协议层天然支持一条消息多个 tool_calls;“并行”指模型一次请求多个调用,执行端串行跑也完全合法(AgentScope 则真的分批并发执行)。
动手挑战
给 ToolRegistry 加一个”危险工具”机制:注册时可标记 dangerous=True,执行前在终端 input() 请求用户确认,拒绝则把”用户拒绝了此操作”回灌给模型。
提示:装饰器要支持参数(@registry.tool(dangerous=True))。这个小机制是第 9 章”人机协同”的种子。
小结
- 一个装饰器 + 反射 = 定义即暴露。
@registry.tool把函数塞进注册表,schemas()再从签名、类型注解、docstring 自动生成 JSON schema。第 1 章手抄 schema、if/else分发的两份”真相”合成了一份,工具签名成了唯一来源,加工具不再动循环。 - 一处
try/except守住所有工具。call()把未知工具名、参数错误、执行异常统统转成字符串回灌。错误是观察、交给模型自我修正——这是第 1 章的原则,本章把它做成了基础设施:绝不让一次工具报错掀翻循环。 - 循环因此瘦身,控制流与工具集解耦。
agent.py和上一章的 diff 里,循环体不再 import math、不再手写TOOLS、分发一行搞定;工具增删循环一字不改。 - 工具之上是技能。工具多到失控时,答案是分组、按需加载、把工具+知识+流程打包,而不是无脑堆更多——见 技能与插件。
- 但我们一直吃着一个前提:模型和服务端支持原生 function calling,会规规矩矩吐出结构化的
tool_calls。要是模型没这本事,或你想换一种”先思考、再行动”的方式呢?那就得改造循环本身——下一章 循环范式(ReAct、plan-execute…)登场。