大脑再聪明,没有手也拿不起杯子。本篇讲工具系统的设计哲学,逐个拆解知了的 13 个内置工具(每个都有值得学的安全细节),最后带你 5 分钟加一个自己的工具。代码对应
zhiliao/tools/和zhiliao/agent/assemble.py。
一、工具系统的两条设计哲学
哲学 1:描述即接口。 模型看不到你的函数源码,它决定"用不用、怎么用"完全基于你写的 description 和参数说明。所以:
# 反面教材:模型不知道什么时候该用、参数什么格式 → 乱用
Tool(name="calc", description="计算", parameters={...})
# 正面教材:说清场景、边界、例子 → 用得准
Tool(name="calculator",
description="计算数学表达式的值,支持 + - * / % ** 、括号、小数。"
"涉及任何算术(哪怕很简单)都应该用它,不要心算,心算容易错。",
...)
工具描述写得像"给新同事的说明书",这是工具层最重要的提示工程。知了的描述里甚至写了行为引导("不要心算")——这是实战技巧:模型心算大数字确实会错。
哲学 2:小而专,不要大而全。
不要造一个 do_everything(action, data, options...) 的万能工具。工具越单一:描述越好写、模型越好选、出错越好查。知了的 13 个工具,每个只做一件事。
二、注册表:工具的"总调度台"
tools/registry.py 一共三个职责:
@dataclass
class Tool:
name: str # 英文名,模型靠它点名
description: str # 中文说明书,模型靠它决策
parameters: dict # 参数 JSON Schema,模型靠它拼参数
func: Callable # 真正干活的函数
class Registry:
def schemas(self): # 翻译成 OpenAI 协议格式发给模型
return [{"type": "function", "function": {...}} for t in ...]
def call(self, name, arguments_json):
# 1. json.loads 解析参数 2. 执行函数 3. 一切异常 → 错误文本
# 4. 结果截断到 2000 字符(太长会撑爆上下文)
call 里的两个细节都是实战教训:解析失败不崩(返回错误说明,模型会重试);结果截断(一个工具输出 10 万字符就能把对话炸了)。
三、13 个内置工具,各有各的看点
| 工具 | 一句话 | 值得看的细节 |
|---|---|---|
get_current_time |
报时间 | 模型知识冻结在训练日,时间必须靠工具——这就是"为什么需要工具"的最小案例 |
calculator |
算数 | 手写解析器代替字符串执行,见下文 |
todo |
待办增删查 | JSON 文件持久化,工具也可以有"自己的状态" |
read_file / write_file / list_files |
文件三件套 | 路径沙箱,见下文 |
fetch_webpage |
抓网页正文 | SSRF 防护,见下文 |
web_search |
搜索 | 三级降级容错:库 → 兜底解析 → 友好失败 |
run_python_code |
跑 Python | 超时强杀 + 输出回流给模型改错 |
remember_fact / recall_facts |
长期记忆读写 | 第 5 篇展开 |
use_skill / search_knowledge_base |
技能/知识库 | 第 6、7 篇展开 |
看点 1:计算器为什么不"一行搞定"?
网上教程普遍把表达式字符串直接丢给 Python 当代码执行。但那等于给模型开了一个任意代码执行入口——模型被诱导输出一段恶意代码,你的机器就交代了。知了手写了一个小而完整的递归下降解析器:
def _tokenize(expr): # 词法分析:"1+2*3" → [("num",1),("op","+"),("num",2),("op","*"),("num",3)]
token_re = re.compile(r"\s*(?:(\d+\.\d*|\.\d+|\d+)|(\*\*|[+\-*/%()]))")
... # 任何不认识的字符在这里被拒——白名单边界
class _Parser: # 语法分析:优先级写在"调用层级"里
def expr(self): # 加减(最低优先级)
def term(self): # 乘除取余
def power(self): # 幂(右结合)
def atom(self): # 数字和括号(最高)
它只认识数字和运算符,想注入都注入不进去。附赠收获:这是编译原理的最小入门样本。
看点 2:文件工具的沙箱
def _resolve_in_sandbox(base, rel_path):
target = (base / rel_path).resolve()
if target != base and base not in target.parents:
raise ValueError(f"路径越界:{rel_path} 不在沙箱目录内") # ../../.env 到此为止
return target
不设防的文件工具 + 一个"帮我清理磁盘"的指令 = 灾难。先解析到绝对路径,再验证仍在沙箱内,顺序不能反。工厂函数 make_file_tools(workspace_dir) 用闭包把沙箱根目录"焊"进三个工具里,外部无法绕过。
看点 3:联网工具的 SSRF 防护
SSRF(服务端请求伪造):模型传来 http://192.168.1.1/admin 或 http://127.0.0.1:6379,你的联网工具就从"对外窗口"变成"探测内网的跳板"。知了的防护三件套(tools/web.py):
def _check_url(url):
if parsed.scheme not in ("http", "https"): raise ... # 1. 协议白名单
for info in socket.getaddrinfo(host, port, ...):
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ...:
raise ValueError(...) # 2. 解析后逐 IP 校验
def _safe_get(url, max_redirects=3): # 3. 关自动重定向,手动逐跳重新校验
...
重定向必须单独防:http://外网.com 302 跳到 http://127.0.0.1 是绕过校验的经典后门。
四、5 分钟加一个你自己的工具
照抄这个模板(以"随机motivation语"为例),在 agent/assemble.py 的 build_registry 里注册:
# 1. 写函数(返回字符串!)
def roll_dice(sides: int = 6) -> str:
import random
return f"掷出了 {random.randint(1, sides)} 点({sides} 面骰)"
# 2. 配说明书
reg.register(Tool(
name="roll_dice",
description="掷一个骰子,返回随机点数。用户想抽签、做随机决定时使用。",
parameters={
"type": "object",
"properties": {"sides": {"type": "integer", "description": "面数,默认 6"}},
},
func=roll_dice,
))
重启 python main.py,对它说"帮我掷个骰子决定谁洗碗"。就这么简单——这就是"功能全面"的真正含义:不是内置功能多,而是扩展成本无限低。
五、工具安全清单(发布前自查)
- [ ] 文件类:有沙箱?绝对路径、
..都拦得住? - [ ] 联网类:协议白名单?内网 IP 校验?重定向处理?
- [ ] 执行类:有超时?有输出上限?生产环境是否进了容器?
- [ ] 通用:异常是否转成文本(而不是让 Agent 崩溃)?结果是否截断?
- [ ] 描述:会不会诱导模型用它做危险事(比如描述里没说"只读"结果模型拿它删库)?
本篇作业
- 照第四节模板加一个自己的工具(建议:
get_weather假实现,或真接一个免费天气 API)。 - 读
tools/files.py,试着构造一个能骗过旧版沙箱的路径,验证现在的拦截(例如notes/../../x.txt)。 - 思考:为什么
run_python_code的临时文件放在 workspace 而不是系统临时目录?(提示:调试便利 vs 隔离,权衡在哪)
下一篇预告:《05 · 记忆系统》——会话历史为什么不能无限堆?滚动摘要怎么实现?跨会话的长期记忆怎么存、怎么查?