工具协议不是函数列表
模型调用外部能力时,参数错误、权限越界和返回值漂移都会进入系统链路。工具协议需要比普通函数签名包含更多运行时信息。
普通函数由可信代码调用,而 Agent 工具的调用者是概率模型。它可能漏字段、使用旧参数、把字符串当数字,甚至尝试访问描述中未开放的能力。因此,工具边界既是类型边界,也是安全边界和可观测性边界。
需要被显式定义的内容
- 输入与输出 Schema
- 幂等性与重试策略
- 超时和取消信号
- 权限等级
- 对用户可见的操作摘要
类型系统负责开发期约束,运行时 Schema 负责处理模型产生的不可信输入。两者结合,才能让工具层在持续扩展时保持稳定。
用同一份 Schema 驱动类型与校验
以 Zod 为例,先定义运行时 Schema,再从中推导 TypeScript 类型,避免接口和校验规则分别维护。
import { z } from 'zod';
const CreateIssueInput = z.object({
repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
title: z.string().min(1).max(120),
body: z.string().max(20_000).default(''),
labels: z.array(z.string()).max(10).default([]),
});
type CreateIssueInput = z.infer<typeof CreateIssueInput>;
const parsed = CreateIssueInput.safeParse(modelArguments);
if (!parsed.success) {
return {
ok: false,
code: 'INVALID_ARGUMENTS',
details: parsed.error.flatten(),
};
}
错误应返回结构化代码和可修正字段,而不是一段堆栈文本。模型可以根据 INVALID_ARGUMENTS 修正一次;数据库断开则依据 retryable 决定是否退避重试。
统一结果信封
每个工具都返回一致的外层结构,业务数据放在 data 中。这样执行器无需猜测异常格式,也更容易记录指标。
type ToolResult<T> = {
ok: boolean;
data?: T;
error?: {
code: string;
message: string;
retryable: boolean;
};
meta: {
traceId: string;
durationMs: number;
version: string;
};
};
返回给模型的内容应精简、稳定且有上限。大文件、长日志和二进制数据只返回摘要与受控引用,避免一次工具调用挤满上下文。
副作用、幂等与审批
我会把工具分成读取、写入和破坏性三类。读取工具通常可以自动执行;写入工具展示变更摘要;删除、付款、发布等破坏性操作必须得到明确确认。
所有可重试的写操作都需要幂等键。执行器超时不代表下游没有成功,盲目重试可能创建重复资源。工具还应返回外部系统的资源 ID,便于查询真实状态或执行补偿。
版本演进
不要在原字段上悄悄改变语义。新增可选字段通常可以保持兼容;删除字段、收紧枚举或改变单位时,应发布新版本,例如 create_issue.v2,并在一段时间内同时支持旧调用。
工具描述同样属于协议。描述要说明适用场景、不适用场景、重要约束和副作用,但不要塞入与调用无关的长文档。可以用一组固定任务做回归测试,观察模型是否仍选择正确工具并生成合法参数。
最小审计记录
一次工具调用至少记录调用者、会话、工具版本、参数摘要、审批人、开始/结束时间、结果状态和外部资源 ID。敏感字段在进入日志前脱敏,原始密钥永不进入 trace。
类型安全不是让模型永远不犯错,而是让错误在边界处被识别、解释和阻止,而不是悄悄扩散到真实系统。