本节摘要:工具系统与模型之间只有一个接口:schema。模型看不见函数体、看不见异常类型、看不见你的目录结构——它每次决策时手里的全部信息,就是每个工具的名称、描述与参数 JSON Schema。因此工具定义不是代码注释,是运行时生效的 API 文档:描述含糊,模型就会错选、错参、错用时机。本节先立四要素契约(名称、描述、参数、返回),再给出装饰器注册器
registry.py(本书第二块积木,约 45 行):从 Python 函数签名与 docstring 自动生成 schema,注册即入册、入册即可被派发;然后是给模型写描述的清单与好坏对照;最后给出工具分类学——读、写、执行、检索、委派五类及其风险分级,为第 5 章权限设计备好输入。
阅读完本节,你应当能够:
| 要素 | 作用 | 影响模型的什么 |
|---|---|---|
| 名称(name) | 唯一标识 | 选不选这个工具(与描述一起构成"索引词") |
| 描述(description) | 这个工具干什么、什么时候用、什么时候别用 | 选择准确率的最大变量 |
| 参数 Schema | 每个参数的类型、含义、必填性、枚举值 | 调参正确率;枚举能显著降低自由发挥 |
| 返回约定 | 成功与失败各返回什么结构 | 模型对结果的解读与下一步决策(4.3 节) |
一个原则贯穿四要素:契约面向模型而非面向程序员。参数叫 path 还是 file_path、描述写"读取文件"还是"读取 UTF-8 文本文件的完整内容,路径相对于工作区根目录"——对程序员等价,对模型天差地别。
registry.py第 3 章 mini_loop.py 里手写了 TOOLS 清单与 TOOL_FUNCS 字典,两处维护必然漂移。正解是单一事实源:函数即工具,注册即登记。
# registry.py —— 装饰器工具注册器(写法示意,以官方文档为准) import inspect, json from typing import Callable _REGISTRY: dict[str, dict] = {} # name -> {"fn": fn, "schema": schema} def tool(fn: Callable) -> Callable: """装饰器:从函数签名 + docstring 自动生成 schema 并注册。""" sig = inspect.signature(fn) props, required = {}, [] for name, param in sig.parameters.items(): anno = param.annotation if param.annotation is not inspect.Parameter.empty else str props[name] = {"type": "string" if anno is str else "integer" if anno is int else "boolean" if anno is bool else "string", "description": ""} if param.default is inspect.Parameter.empty: required.append(name) doc = inspect.getdoc(fn) or "" _REGISTRY[fn.__name__] = { "fn": fn, "schema": {"type": "function", "function": { "name": fn.__name__, "description": doc.split("\n\n")[0], # 首段作描述 "parameters": {"type": "object", "properties": props, "required": required}}}, } return fn def schemas() -> list[dict]: """供主循环①感知拍使用:装入上下文 L3 层的契约清单。""" return [item["schema"] for item in _REGISTRY.values()] def dispatch(name: str, args: dict) -> str: """供主循环③行动拍使用:查册执行,错误也走 4.3 的结构化约定。""" item = _REGISTRY.get(name) if item is None: return json.dumps({"error": {"type": "unknown_tool", "message": f"未注册的工具: {name}"}}, ensure_ascii=False) try: return json.dumps({"ok": True, "result": item["fn"](**args)}, ensure_ascii=False, default=str) except Exception as exc: return json.dumps({"error": {"type": type(exc).__name__, "message": str(exc)}}, ensure_ascii=False)
用法与收益:
@tool def read_file(path: str) -> str: """读取 UTF-8 文本文件的完整内容。path 为相对于工作区根目录的路径。 适用于查看源码、配置与文档;不要用它读二进制文件。""" return open(path, encoding="utf-8").read()
schemas() 替换 mini_loop.py 的手写 TOOLS,dispatch() 替换手写 TOOL_FUNCS——第 3 章积木与本章积木就此咬合。dispatch 里 item["fn"](**args) 那一行之前,就是第 5 章权限门的插入位(与 3.1 节预告一致)。描述是 schema 里杠杆最大的一项。一份自查清单:
好坏对照:
| 坏描述 | 好描述 |
|---|---|
| "编辑文件" | "对文件做精确字符串替换:给出旧串(必须与文件内容逐字一致)与新串。适合小改动;大范围重写请用 write_file" |
| "执行命令" | "在项目根目录执行 shell 块。有副作用且不可自动撤销。只读检查类命令优先用 grep/glob 工具" |
💡 一个省力技巧:把"何时别用"写成工具间的互相指引(Read 的描述里提 Grep,Grep 的描述里提 Read)——模型在边界场景里会顺着这些指引自我纠偏,这比堆十条例规都管用。
| 类 | 例子 | 副作用 | 风险级 | 权限默认(第 5 章用) |
|---|---|---|---|---|
| 读 | read_file、list_dir、glob | 无 | 低 | 可 allow |
| 检索 | grep、web_search | 无(外发查询除外) | 低 | 可 allow(网络类单独看) |
| 写 | write_file、edit_file | 改工作区 | 中 | ask 或受限 allow |
| 执行 | bash、代码解释器 | 任意 | 高 | ask + 沙箱 |
| 委派 | task / 子智能体 | 间接全部 | 视子集而定 | 继承子集策略 |
两个设计结论:其一,"任意执行"是风险分水岭——第 2.3 节的规律(沙箱 ⟺ 允许任意执行)在这里落到工具分类上:提供 bash 类工具的那一刻,你就需要第 6 章的沙箱。其二,分类是给权限门的输入——第 5.1 节的 allowlist 将直接按这张表的"权限默认"列展开。
registry.py:装饰器注册实现单一事实源,schemas() 喂感知拍、dispatch() 供行动拍,权限挂点已留好。工具定义好了,循环每次都把全部 schema 装进上下文 L3 层——工具少时没问题,多到几十个时呢?下一节讲工具选择与路由:什么时候让模型自己挑,什么时候在它前面加一道筛子。