第 4 章 工具层设计¶
模型看不到工具的实现,只能看到名称、描述与参数模式。工具层因此首先是一份契约:模型据此判断何时调用、填什么参数、拿到什么结果;harness 据此校验、执行、回灌与审计。本章按"契约 → 粒度 → 校验 → 回灌 → 并行 → 发现 → 副作用 → 预演"的顺序,给出工具设计的判据。
1. 工具是契约,不是函数¶
一个工具对模型暴露四样东西:名称、描述、参数模式、返回值形状;对 harness 还多一样:错误语义。前四样决定模型能否正确使用,第五样决定失败后能否恢复。把这五样写清楚,比实现本身更影响成功率。
名称是稳定标识:日志聚合、权限规则、用例断言都按它索引,改名等同于换一个工具,旧轨迹与旧用例全部失效。描述是模型唯一能读到的文档,实现注释对它不可见;错误语义要列成可枚举的码(参数无效 / 目标不存在 / 权限拒绝 / 暂时不可用),并用一个字段标明是否可修正——模型据此决定改参数还是换路径。
判据:模型能否仅凭描述判断"什么时候该用、什么时候不该用、失败了怎么办"。描述里只写"做什么"是不够的,边界(不适用场景)、副作用(读还是写)、返回规模(是否可能很大)都要写进去。
2. 模式与描述的五个要素¶
名称用"动词加对象",保持稳定,不用同义词轮换;描述写边界与副作用;参数模式约束类型与取值范围;返回值声明形状与规模;错误语义说明哪些失败可修正。
{
"name": "apply_patch",
"description": "按统一 diff 修改工作区文件;会写入磁盘。路径必须位于工作区内,冲突时整体失败不回滚部分修改",
"parameters": {
"type": "object",
"properties": {
"diff": {"type": "string", "description": "统一 diff 文本"},
"dry_run": {"type": "boolean", "default": false}
},
"required": ["diff"],
"additionalProperties": false
},
"returns": {"applied": "integer", "rejected": "array<string>"}
}
描述里"整体失败不回滚"这类语义必须写明,否则模型会在部分失败后继续基于错误假设推进。返回值声明让模型知道结果规模,避免一次调用回灌整份文件。
参数模式按"能约束就约束"写:枚举优于自由字符串,格式用 pattern 或 format,additionalProperties: false 关掉意外字段;required 只列真正必需的项,可推导的给默认值并在描述里注明。字段数控制在七个以内——再多就说明这个工具做了不止一件事。厂商标称的"严格模式"只支持 JSON Schema 的一个子集,oneOf、条件约束一类未必生效,写进契约前以厂商文档支持的子集为准。
3. 粒度与数量¶
工具粒度有两端:少而粗(一个工具多个参数、组合语义)与多而细(每个动作一个工具)。粗粒度减少选择成本,但参数组合爆炸、错误难定位;细粒度语义清晰,但工具数量增长会挤占上下文,并降低选择准确率。
数量上限是经验值而非硬指标:单层暴露二十到三十个工具后,选择准确率开始明显下滑,此时应分组或启用懒加载;分组按使用场景而不是按实现模块,同一场景里的读、写工具放一组,避免模型为了完成一件事跨组来回选。
判据:一个工具做一件可命名的事。不要为每个参数组合开新工具,也不要把"读、改、发"塞进同一个工具。工具数量的实际上限由上下文预算决定(第 3 章):每个 schema 都是固定成本,工具越多,留给历史与输出的额度越少。
4. 参数校验与失败语义¶
校验在派发前完成,失败按可修正性分两类。可修正:字段缺失、类型错误、越界、枚举外取值——把"字段、期望、实际"回传,模型通常能自行修正。不可修正:工具不存在、参数无法表达意图、目标资源不存在——应立即终止或换路径,不要反复重试。
校验分三层:模式层(类型与枚举)、语义层(路径在工作区、时间区间合法、引用存在)、资源层(配额、设备占用)。三层失败的回传内容不同:模式层给字段与期望值,语义层给命中的规则,资源层给当前状态与释放条件。错误对象按 code / field / expected / actual / retryable 输出,模型才能区分"改一下参数就行"与"这个工具根本做不了"。
连续失败要计数:同一工具同一类错误重复出现时,换工具或换策略,而不是继续采样。把失败原文与校验规则一起回灌,比只说"参数错误"有效;但不要把整个 schema 再贴一遍,那是重复的固定成本。
5. 结果回灌:序列化、截断与分页¶
工具结果最终会变成 token,因此回灌的是"够决策的最小子集",不是全量输出。三种手段按场景选:截断保留首尾与关键行(日志、报错)、分页给偏移与总数(长列表)、结构化摘要给计数与样例(大批量结果)。二进制与图像走路径或引用,不内联进上下文。
回灌前先清理:去 ANSI 控制符、去进度条回退字符、把进度输出压成最后一行。结果顺序要稳定(排序后再输出),否则模型会把排列差异当成内容变化。大输出优先走"落地文件 + 摘要 + 路径",模型需要细节时再按行区间读取,避免一次把上下文灌满。
def clip(text: str, limit: int = 4000, keep_head: int = 1500, keep_tail: int = 1500):
if len(text) <= limit:
return text
omitted = len(text) - keep_head - keep_tail
return (text[:keep_head]
+ f"\n... [{omitted} chars omitted] ...\n" # 明确告知截断,避免模型以为读完了
+ text[-keep_tail:])
截断必须显式标注,否则模型会把"看到的部分"当成"全部内容"。分页结果要带总数与下一页游标,让模型能判断是否值得继续读。
6. 并行调用与依赖¶
无依赖的读操作可以并行,写操作按资源串行或加锁。并行结果靠调用标识配对,逐条返回成功或失败;不要用一个总的成功标志掩盖部分失败。
并行度要受两头上限约束:服务端的限流与本地上下文预算。十个读调用同时返回,光是结果就占满窗口;按结果体量分批,而不是按调用数量。汇总时保留原始顺序,失败项带上错误对象,不要让"部分成功"在回灌里看起来像"全部成功"。
依赖判定不能交给模型自觉:同一轮里两个写操作是否冲突,由 harness 按资源键判断。冲突时要么串行化,要么让其中一个等待;把两个写操作同时派发,再指望文件系统兜底,是把一致性问题推给运气。
7. 工具发现与懒加载¶
工具数量膨胀后,两段式发现比全量声明更稳:先让模型(或检索器)按任务选工具组,再加载该组的完整 schema。命名空间前缀(如 fs.、ros.)帮助分组,也降低重名概率。
检索索引建在名称、描述与参数名上,三者都要包含任务里可能出现的词;只索引名称会让"把图片缩小"搜不到 resize_image。选错组的代价是一整轮往返,因此分组要粗到能覆盖常见任务,而不是按每个动词细分。工具改名等于破坏索引,稳定名称在这里第二次起作用。
判据是成本与准确率的交换:全量声明多付固定 token,换取一次选择;懒加载省 token,但多一次往返,且选错组会多一轮纠正。任务边界清晰时用懒加载,工具少且常用时全量声明。
8. 副作用分级与幂等¶
按副作用把工具分三档:读(无副作用)、写(可撤销或可重放)、不可逆(删除、覆盖、对外发送、真机运动)。分档不是为了标注好看,而是为了权限门(第 5 章)与重试策略(第 2 章)能按档位取值。
三档对应三套重试策略:读随便重试;写只在有幂等键时自动重试,否则先查询实际状态;不可逆先确认再执行,失败也不自动重发。幂等键用业务标识(任务 id、目标路径与内容哈希)生成,不用时间戳——时间戳让每一次重试都成为新动作。
写操作要带幂等键:同一逻辑动作重复提交时,工具侧按 操作标识 + 参数摘要 去重。没有幂等键的写操作,重试前必须先查询实际状态。真机动作还要额外带"在途"状态,避免把正在执行的动作当成失败重发(第 10 章)。
9. 试跑与预演¶
试跑(dry-run)与确认(confirm)是两件事:试跑真实计算但不落地,返回"将要发生什么";确认是在试跑结果之上取得授权。不可逆动作应同时具备两者——先给影响面(改哪些文件、动哪些关节、发往哪个地址),再请求确认。
试跑输出要结构化:变更清单(路径 + 增删行数)、影响面计数(任务数、目标数)、警告项(越界、覆盖已有文件、将删除非空目录)。试跑与真正执行之间存在时间差,目标可能在两者之间被改动——执行前按目标摘要再比对一次,不一致就重新预演,不要直接执行旧结论。
预演要真的走一遍解析与校验,只跳过最后一步写入;只做字符串检查的"预演"会在真正执行时暴露参数错误,失去预演的意义。
10. 要点¶
- 工具是契约:名称、描述、参数模式、返回形状、错误语义缺一不可。
- 描述要写边界、副作用与失败语义,而不只是"做什么"。
- 一个工具做一件可命名的事;工具数量的上限由上下文预算决定。
- 校验在派发前,失败按可修正性分类;同类失败连续出现就换路径。
- 回灌够决策的最小子集;截断必须显式标注,分页要带总数与游标。
- 并行只用于无依赖读操作;写操作按资源键串行。
- 副作用分读、写、不可逆三档;写操作带幂等键。
- 预演真实计算但不落地,确认在预演结果之上发生。