KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03. 一个工具契约究竟包含什么? — keel 龙骨
上一章末尾留下了一个问题:模型确实能提出工具请求,但「一个函数」和「一个能交给模型的工具」之间,到底差了哪些字段?
上一章末尾留下了一个问题:模型确实能提出工具请求,但「一个函数」和「一个能交给模型的工具」之间,到底差了哪些字段?
现场:Alice 把函数直接注册上线
Alice 拿到一个现成的内部函数:
def lookup_incident(incident_id: str) -> dict:
...
签名很清晰:传字符串进,还字典回。她把它注册给模型,上线第一周出了三件事:
- 用户问「INC-404 怎么样了」,这个函数抛了
KeyError,异常文本连同字典一起进了模型上下文,助手把「'INC-404'」当成事实念给用户; - 有人在另一个租户下问同一句话,函数照样返回了数据——它内部没有租户维度;
- 两周后上游改了返回值,删掉
evidence字段,模型读到半截数据继续推理,输出了一份没有证据支撑的结论。
注意这三件事的性质:没有一件是模型调用错了。工具选对了、参数格式对了、请求也执行成功了。出问题的是「这个函数从来没有被当成一份契约来描述」,所以超时、权限、输出形状、业务失败这些事只能靠调用方猜。
六组问题,一张契约
ToolContract = Identity + Input + Output + Effect + Reliability + Authorization
| 契约项 | 回答的问题 | 典型字段 |
|---|---|---|
| Identity | 叫什么、哪一版 | name / version |
| Input | 参数形状和取值约束 | arguments_model |
| Output | 成功结果长什么样 | result_model |
| Effect | 只读、写业务状态、还是影响外部世界 | effect |
| Reliability | 超时怎么办、能不能重试、结果未知时查谁 | timeout / retryable / 状态查询入口 |
| Authorization | 谁能对哪些资源做这个动作 | 主体 + 动作 + 资源,通常由外部策略服务决定 |
关键的不对称:这六项不是给同一个读者看的。模型只需要知道「有没有这个工具、能不能解决眼前的问题、参数怎么填」;超时、版本、effect、审批和租户粒度是执行器和策略系统的输入,模型看不见也不需要看见。把这两半混在一起写,最常见的后果是「用 prompt 告诉模型不要越权」——那不是授权,那是请求。
跑一遍就有体感:
python courses/foundation/tool-calling/course/project/examples/09_offline_advanced.py
第一组实验把同一份 ToolDefinition 切成两半打印出来:
--- ① 契约卡片:模型看到的三项 vs 执行器依赖的六项 ---
模型可见(它靠这些决定「要不要调、传什么」):
name = lookup_incident
description = 按事件编号查询故障现象、服务、严重级别和证据
parameters.required = ['incident_id']
parameters.properties = ['incident_id']
模型看不到,但每次执行都用得上:
version = 1.0
effect = read
result_model = IncidentRecord
handler = lookup_incident
两边有没有重叠字段? set()
最后一行是这一节最有价值的一句:两边没有重叠。
模型可见的部分里,description 是其中的自然语言字段,它决定模型什么时候选这个工具;parameters 由 arguments_model.model_json_schema() 自动生成,手写 schema 和真实校验逻辑迟早会漂移。内部那一半里,容易被漏掉的是 version——工具迭代后,同一份历史事件必须还能回放,没有版本就无从对齐。
三层检查:形状、可行性、权限
一个参数从模型到达业务函数,中间要过三道性质完全不同的检查:
Schema -> 值的形状合法吗?(INC-404 的形状是对的)
Business -> 这个对象存在、处于可执行状态吗?(INC-404 不存在)
Policy -> 当前主体对这个资源有权限吗?(Alice 不能查另一个租户的事件)
混淆这三层会产出两类典型 bug。把 Business 塞进 Schema:service 下线了还得改代码、重新发版才能让参数校验通过。把 Policy 塞进 prompt:写在系统提示里的「你只能查自己租户的数据」不构成任何强制力,模型漏一句绕过就绕过了,而且事后审计时你拿不出「当时做了什么判断」的记录。
对应到前面的现场三件事:第 1 件缺 Business 层(不存在应该是一个受控错误,不是异常);第 2 件缺 Policy 层;第 3 件缺 Output 契约。
输出也要有契约
大多数教程只讲输入校验。但工具返回值同样会违约,而且它的后果更隐蔽——错误数据不会报错,它会安静地进入下一轮上下文。
class IncidentRecord(BaseModel):
model_config = ConfigDict(extra="forbid")
incident_id: str
severity: Literal["low", "medium", "high", "critical"]
symptom: str
evidence: list[str]
09_offline_advanced.py 的第二组实验注册了一个故意少返回 evidence 的工具:
--- ② 输出契约被破坏 ---
ok = False
code = invalid_tool_output
data = None <- 残缺结果不会进入上下文
说明:handler 没有抛异常,程序也没有崩;是 result_model 拦下的。
注意这里的措辞:是执行器拦下的,不是靠函数自觉。只要 policy 判定这段结果不能进上下文,data 就必须是 None。让「半截数据」和「完整数据」共用同一个通道,等于放弃了在协议层止损的机会。
extra="forbid" 是同一件事的另一面:上游多返回一个字段也不该被静默接受,否则工具实现就可以擅自扩展契约而没人知道。
call_id:把请求、结果、事件串起来的主键
class ToolCall(BaseModel):
call_id: str = Field(min_length=1)
name: str = Field(pattern=r"^[a-z][a-z0-9_]*quot;)
arguments: dict[str, Any]
工具名不足以标识一次调用——同一轮里两个 lookup_incident 是两次独立调用。call_id 由 Harness 生成({run_id}:step-{n}:call-{m}),请求、结果、事件日志、审批记录、幂等键都以它为外键。
为什么不让模型提供?因为不同供应商的 id 字段语义不同,重放、去重、跨模型对比都要在内部统一。第 08 章那组乱序实验会给出一个更硬的理由:结果到达的顺序不等于请求顺序,能用来归位的只有 call_id。
ToolError 的三个字段各自管什么
{
"code": "incident_not_found",
"message": "没有找到事件 INC-404",
"retryable": false
}
code:给程序判断,决定是否重试、是否终止、是否转人工;message:给人和模型读,要能解释「发生了什么」而不泄露内部细节;retryable:建议,不是承诺。真要重试还得看 effect 和幂等(第 07 章)。
把 code 写成 something_went_wrong 等于没有错误分类;把 message 写成原始异常文本等于把内部实现暴露给模型。
主动破坏:四个改法
改 project/src/tool_calling_course/tools.py 后重跑 09_offline_advanced.py,观察哪一步先抓住:
| 改法 | 预期现象 | 被哪一层抓住 |
|---|---|---|
IncidentRecord 删掉 evidence |
输出契约组从报错变成通过 | 无人把守,改错了也看不出来 |
去掉 extra="forbid" |
上游多返回字段不会被拒 | 契约扩展失去监督 |
symptom 写成 HTML 片段 |
结果照进上下文 | 输出契约只管结构不管内容策略 |
description 写成「查询所有数据」 |
模型更容易误选它 | 没有自动检查,只能靠评审 |
前两个是「防线被拆除」,后两个是「有些事本就该由别的层负责」——这正是下一章划分层和过早纪律的意义。
生产替换点
| 教学实现 | 生产替换 |
|---|---|
arguments_model / result_model |
契约测试 + schema registry,跨服务共享同一份定义 |
内存 _TICKETS_BY_KEY |
带 UNIQUE 约束的持久化存储(幂等键上建索引) |
effect 常量字段 |
集中的工具元数据服务,发布时校验 effect 与实现一致 |
ExecutionContext.allow_side_effects 布尔值 |
策略引擎 + 可回放的审批记录 |
description 手写文本 |
人工评审 + 回归用例,防止描述漂移成「万能工具」 |
练习与验收
练习:给 get_service_status 补一份完整契约卡片,按「模型可见 / 仅内部」分两栏,并在每一格里写出「这一格如果错了,事故长什么样」。
验收标准:至少有一格你会写成「不确定」——把那一格标出来,它就是下一章要解决的东西。
本章检查点
- 现场那三件事分别缺了哪一层?给出判断依据,不要只报层名。
- 为什么 Model Visible 与 Internal 两半「没有重叠」是好事?如果某个字段两边都需要(比如
effect),你会在哪里各存一份? - 输出契约失败时为什么
data必须是None,而不是返回残缺数据让模型自己判断? retryable=false的工具,执行器就一定不会重试吗?谁说了算?
现在能解释什么
你能说清「工具」不是函数,而是一份六项契约:模型只看其中一半,另一半由执行器和策略系统在每次调用时强制执行。你也知道输出同样是契约的一部分、缺了它错误会延迟到下游才被发现。
但到这里为止,handler 一直是一个已经存在的 Python 函数对象。下一章把这个前提拿掉:工具的元数据躺在数据库里,handler 变成一行静态记录里的字符串——那时「注册」意味着什么?