KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03. 一个工具契约究竟包含什么? — keel 龙骨

上一章末尾留下了一个问题:模型确实能提出工具请求,但「一个函数」和「一个能交给模型的工具」之间,到底差了哪些字段?

上一章末尾留下了一个问题:模型确实能提出工具请求,但「一个函数」和「一个能交给模型的工具」之间,到底差了哪些字段?

现场:Alice 把函数直接注册上线

Alice 拿到一个现成的内部函数:

def lookup_incident(incident_id: str) -> dict:
    ...

签名很清晰:传字符串进,还字典回。她把它注册给模型,上线第一周出了三件事:

  1. 用户问「INC-404 怎么样了」,这个函数抛了 KeyError,异常文本连同字典一起进了模型上下文,助手把「'INC-404'」当成事实念给用户;
  2. 有人在另一个租户下问同一句话,函数照样返回了数据——它内部没有租户维度;
  3. 两周后上游改了返回值,删掉 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 写成 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 补一份完整契约卡片,按「模型可见 / 仅内部」分两栏,并在每一格里写出「这一格如果错了,事故长什么样」。

验收标准:至少有一格你会写成「不确定」——把那一格标出来,它就是下一章要解决的东西。

本章检查点

现在能解释什么

你能说清「工具」不是函数,而是一份六项契约:模型只看其中一半,另一半由执行器和策略系统在每次调用时强制执行。你也知道输出同样是契约的一部分、缺了它错误会延迟到下游才被发现。

但到这里为止,handler 一直是一个已经存在的 Python 函数对象。下一章把这个前提拿掉:工具的元数据躺在数据库里,handler 变成一行静态记录里的字符串——那时「注册」意味着什么?

上一章:先让模型真正提出一次工具请求 · 下一章:数据库里的一行静态记录,怎样变成可调用的 function?

进入 keel 阅读