工具层设计:给行动上锁的人
大语言模型本身只能接收信息、生成文本。它能判断"该读这个文件""该跑这个测试""该查这张表",但只要没有外部系统替它落地,这些判断就还只是一段文字。工具层就是把模型的行动意图转成真实操作的那一层——它也是整个 Harness 里最该被严格治理的边界,因为它正处在"语言意图"变成"真实副作用"的位置。
TL;DR
工具层不是一组函数,而是一套受控行动系统。它用名称、描述和 Schema 告诉模型工具是什么(描述),用注册表决定当前能看见哪些(发现),用权限和参数验证挡住危险请求(授权),在超时与资源边界内接触外部世界(执行),再把成功与失败都统一成可理解的观察(观察)。五个词记住它:描述、发现、授权、执行、观察。
一、工具层解决三个问题,处在三条边界中间
把工具层的职责拆开,本质是三个问题:
- 工具抽象:文件读取、shell、HTTP、数据库底层完全不同,怎样让运行时用同一种契约调度它们?
- 工具执行:模型生成的调用请求,怎样安全地走完"验证→执行→反馈"而不失控?
- 工具发现:Agent 在当前这一刻,到底应该看见哪些能力?
要回答这些,先得分清三个容易混的角色:
用户目标
→ 技能(Skill):建议"怎样做"(先查 CI 状态,再读日志,最后改代码)
→ 运行时(Runtime):决定"何时推进循环",识别工具请求
→ 工具层(Tool Layer):受控地执行"具体动作"
→ 外部系统:文件、命令、网络、数据库
工具是底层原子能力(读文件、执行命令),职责单一、由系统管理;技能是高层工作方法,描述怎样组合多个工具完成一类任务;运行时是调度者。边界不能糊:技能不该绕过工具层直接改外部世界,运行时也不该把每种工具的安全规则写进主循环。
二、统一工具契约:一份写给模型的合同
一个工具要同时服务两个使用者:运行时关心它能不能调、怎么执行、怎么处理错误;模型关心它能做什么、该传什么。所以一份完整契约至少包含六件东西:
class Tool:
name: str # ① 唯一、稳定的能力标识,如 file_read、bash_exec
description: str # ② 写给"模型"的路由提示:适合/不适合/限制/与近似工具的差异
input_schema: dict # ③ JSON Schema:字段、类型、必填项、取值范围
def check_permissions(self, ctx) -> bool: # ⑤ 把"会做"和"允许做"分开
...
async def run(self, args, ctx) -> "ToolResult": # ④ 异步接口 + ⑥ 统一结果
...
- 名称是模型生成调用时用的标识,要具体、稳定,避免几个含义模糊的工具互相竞争。
- 描述不是给开发者的注释,而是模型选工具时的输入:写短了误选,写长了烧上下文,它本质上是"工具路由提示"。
- 输入 Schema 有三重作用:告诉模型怎么构造调用、执行前验证参数、自动生成文档并适配不同供应商的工具格式。但要记住——Schema 只能证明结构正确,不能证明操作安全:路径在 Schema 里是字符串,却可能逃出工作区;命令在 Schema 里也是字符串,却可能
rm -rf。结构验证之后,必须再做语义与安全验证。 - 异步接口让运行时能管理超时、取消、进度和并发。但要警惕一个坑:函数写成
async不等于真正非阻塞——如果它内部直接调阻塞式文件 IO 或subprocess.run,照样卡住事件循环。这是教学实现走向生产时必须补的课。
三、注册表:能力目录、路由表、Schema 缓存
统一契约解决了"工具长什么样",注册表解决"工具在哪里"。它承担三种职责:
- 注册与查找:启动或动态加载时按名称放入,执行请求到达后按名称取出,调用方不必知道工具类在哪个模块。
- 向模型暴露:收集每个工具的名称、描述、Schema,生成模型可调用的工具列表——保证"模型看到的定义"和"真正可执行的实例"同源。
- 缓存 Schema:避免每轮重建。但缓存就有失效问题——工具定义一更新,缓存必须同步刷新,否则模型拿到的是过期的参数合同。
所以注册表不是个字典。生产级注册表还要管版本、来源、健康状态、优先级和生命周期。
四、六阶段执行流水线:从"模型想做"到"系统做过"
模型说"调用工具",系统不能立刻照办。一次可靠调用要穿过六个阶段,任一阶段失败就提前返回,绝不继续:
模型请求
│
▼
①发现 ──▶ ②权限 ──▶ ③验证 ──▶ ④执行 ──▶ ⑤结果 ──▶ ⑥审计
│ │ │ │ │ │
不存在 越权 越界/危险 超时/异常 脱敏/截断 谁在何时做了什么
└─────────┴──────────┴──────────┴──────────┴──▶ 任一步失败:返回"有类型的错误"
- 发现最便宜,最先做:按名称找不到就立刻返回"工具不存在",不要猜相似工具。
- 权限要在解析复杂参数、占用资源之前做,而且必须拿到真实上下文(用户、项目、审批状态)。如果流水线永远传入空上下文,权限钩子就只是摆设。
- 验证分两层:先 Schema 结构检查,再路径越界 / 危险命令 / 资源上限这类语义安全检查。结构对 ≠ 请求安全。
- 执行才真正接触外部世界,必须被超时、取消、资源限制、异常隔离包住。
- 结果做脱敏、截断、序列化——但截断时要明确告诉模型"原始多大、被截断了",否则它会把残缺当完整。
- 审计记下耗时、结果摘要、错误类型,支撑调试、复现、恢复和缓存。
整条流水线最关键的设计,是结果的统一表达。工具失败不该把异常抛到主循环让循环崩溃,而该变成一条有类型的观察:
@dataclass
class ToolResult:
ok: bool
content: str # 反馈给模型的观察
error_type: str | None = None # not_found / permission_denied / timeout / invalid_args / oversized
elapsed_ms: int = 0
meta: dict | None = None # 原始大小、是否截断、来源等
"文件不存在""权限不足""执行超时"对模型意味着完全不同的下一步:换路径、向用户申请、还是重试。如果全部只返回一句"失败",模型就失去了自我修复的线索——工具失败不是循环崩溃,而是一条有类型的观察,这是工具层和普通函数库最根本的区别。
五、权限与安全:Agent 的最后一道行动边界
模型输出本质上是不可信输入。即使系统提示写了"不要执行危险命令",工具层仍要独立验证——因为提示能被绕过,边界不能。
权限要分层,而不是只有允许/拒绝。 不同工具风险天差地别:
| 工具类型 | 典型能力 | 主要治理重点 |
|---|---|---|
| 执行工具 | shell、Python、文件读写 | 权限、路径、命令安全、超时、副作用 |
| 网络工具 | HTTP、搜索、第三方 API | 凭据、速率限制、重试、外部数据可信度 |
| Agent 工具 | 子 Agent、并行委托 | 成本、递归深度、任务边界、结果聚合 |
| 专用工具 | 数据库、图像、代码分析 | 领域参数验证、依赖管理、专用资源限制 |
安全规则要靠近副作用。 路径边界检查放文件工具附近,命令检测放 shell 工具附近,凭据和速率放网络工具附近。统一网关处理角色和会话授权,但具体工具最懂自己的危险边界——这样即使将来冒出新的调用入口,也绕不过安全规则。两层都不能省。
高风险能力默认拒绝。 命令执行工具不该默认开放完整 shell,更稳的起点是:
允许列表 + 禁止 shell 展开 + 拦截控制操作符与危险命令 + 执行超时 + 限定工作区
随着信任提升再逐步放开,这比"先给全权限再补洞"可靠得多。
并发也要治理。 两个读取可以并发,但"写同一个文件"的两次操作不能只因为"都是 async"就同时跑。能不能并发取决于依赖关系、副作用和资源竞争,而不是性能冲动——"写入后再读取"必须顺序,多个改同一状态的工具并发会产生竞态。
六、扩展与动态发现:别把全部能力永久塞给模型
把所有工具一次性交给模型,看起来能力最强,实际带来两笔成本:工具描述占满上下文,相似工具增多后模型更容易选错。
三种扩展方式,都服务同一目的——减少模型要做的选择:
- 装饰器:给已有工具叠加超时、重试、日志、指标。注意装饰顺序——"整体超时包住重试"和"每次重试各自超时"是两种完全不同的行为。
- 复合工具:把固定的多步流程包成一个高层能力,减少模型重新规划的负担,但要保留中间步骤可观察性,并想清楚某步失败时是继续、回滚还是返回部分结果。
- 条件工具:同一接口下按环境选本地或远程实现(本地读文件 vs 远程调存储 API),前提是选择规则可解释。
动态发现让能力随上下文出现:延迟加载使数据库工具只在管理员会话可见、项目工具只在进入对应仓库后加载;分层优先级(系统 / 会话 / 用户 / 领域 / 内置 / 动态发现)承认工具可见性来自多个作用域,需要清晰的覆盖与冲突规则;MCP 允许外部服务器在运行时声明工具——但它扩大能力边界的同时也扩大了信任边界,遵守协议不等于可信,来源、Schema、权限、结果仍要验证。
缓存的真正难点从来不是写入,而是失效:
Schema 缓存 → 工具版本一变就得刷新,否则模型看到过期参数合同
结果/状态缓存 → 文件被改、权限变化后,旧缓存会让模型基于错误事实行动
七、两种风格的取舍:Claude Code 与 OpenClaw
没有放之四海皆准的工具层。把两种有代表性的设计并排,更容易看清取舍:
| 维度 | Claude Code 风格 | OpenClaw 风格 |
|---|---|---|
| 核心抽象 | 强调输入/输出/进度的类型化工具 | Tools 与 Skills 分层,协议更灵活 |
| 执行方式 | 支持流式反馈与并发调用 | 更强调顺序、确定与可追踪 |
| 权限 | 工具级权限钩子 | 分层策略与访问级别 |
| 发现加载 | 预定义结合延迟加载 | 多作用域、分优先级加载 |
| 缓存 | 文件状态等上下文缓存 | 与会话和技能组织结合 |
| 适用目标 | 深度集成的编码 Agent | 可扩展的工具与技能平台 |
前者重类型、进度、并发和任务体验,适合编码场景;后者重能力组织与可扩展发现,适合做平台。选哪种,取决于你在做一个"很懂某件事的 Agent",还是一个"能长出很多能力的底座"。
八、五个绕不开的设计决策
真正动手设计工具层时,这五个问题逃不掉,而且大多没有标准答案,只有取舍:
- 接口多强类型? 强类型输入输出易于静态检查和维护;字典 + Schema 更灵活、更好适配供应商和远程工具。Python Harness 常用折中:运行时字典 + Schema 验证 + 可选类型模型。
- 顺序还是并发? 不要把并发当默认性能开关。先判断工具是否独立、有无副作用、是否竞争同一资源,再决定。
- 权限在工具内还是统一策略? 工具内部适合领域安全,统一策略适合用户/角色/会话授权。成熟系统两者都要。
- 全预加载还是按需发现? 工具少时静态注册最简单;工具多、来源多、权限差异大时,按上下文加载,别让模型看见无关能力。
- 缓存什么、怎么失效? Schema、结果、外部状态都能缓存,但一致性要求不同。没有可靠失效策略时,缓存比重复调用更危险。
九、一次调用的完整旅程
把上面的东西串起来,一次理想的工具调用是这样走完的:
1. 模型据消息/技能/工具描述,生成结构化工具请求
2. 运行时识别请求,把名称 + 参数 + 真实会话上下文交给流水线
3. 流水线经注册表或动态发现找到工具实例
4. 权限系统判断身份/项目/审批是否允许
5. 先 Schema 结构验证,再路径/命令/领域安全验证
6. 工具在超时、取消、资源限制下执行
7. 执行过程按需产出进度事件
8. 原始输出被序列化、脱敏、截断,转成统一结果
9. 调用记录进审计日志,必要结果进缓存或记忆
10. 运行时把结果写回消息历史,模型当作新观察,决定下一步
这条链说明:真正难的从来不是"让模型调用一个函数",而是让不确定的模型输出,通过一套确定的工程系统,安全地影响外部世界。
十、常见误区
把这一层想清楚,往往是从识破几个"看起来对,其实不对"开始的:
- 调用成功 = 工具层做完了。 单次成功只证明"能跑"。越权、超时、输出过大、并发冲突、失败恢复这些路径没验证过,就还没完成。
- 有 JSON Schema = 安全了。 Schema 只管结构。越界路径、危险命令、恶意 URL 都能通过结构检查。
- 工具越多越好。 能力越多,选择空间越大,误选率和上下文成本越高。暴露应当服务当前任务。
- 出错就该终止 Agent。 多数工具错误应转成结构化观察,让模型改参数、换工具或求助;只有系统级不可恢复错误才终止循环。
- 定义了流水线 = 用上了流水线。 主循环若仍在直接调工具,那流水线里的权限、验证、审计就只是旁路代码。要追踪真实调用链,而不是看设计图。
最小心智模型
如果只留五个词:描述、发现、授权、执行、观察。 描述用名称和 Schema 告诉模型工具是什么;发现决定本轮能看见哪些;授权在副作用发生前确认谁能做什么;执行在边界内完成动作;观察把成功与失败都统一反馈回去。
衡量工具层好不好,不看"接了多少工具",而看这几个问题有没有明确答案:
- 模型能否稳定选对工具?
- 错误参数能否在副作用发生前被拒绝?
- 高风险动作能否被权限和策略控制?
- 工具失败后模型能否拿到足够信息继续?
- 所有真实动作是否可追踪、可审计、可恢复?
- 工具定义与运行时真实执行路径是否一致?
当这些都有明确答案时,工具层才真正从"函数集合"长成 Harness 的核心行动子系统。