KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01. 函数为什么不能直接交给模型? — keel 龙骨

先看一条请求。

先看一条请求。

{
  "name": "lookup_incident",
  "arguments": {"incident_id": "INC-001"}
}

这不是一次 Python 函数调用,而是模型提出的结构化建议。它的全部内容就是「我建议调用这个名字,参数大概长这样」。程序还要自己决定:这个名字是否允许、参数是否被认可、当前用户能不能查这条事件、这次执行要不要留证据。

把这中间的步骤省掉,代码就会变成这样:

result = globals()[model_output["name"]](**model_output["arguments"])

模型输出由此获得了它本不该拥有的程序权限——它能叫出你代码里的任何名字。

Function、API、Tool 分别是什么

名称 解决的问题 例子
Function 程序内部如何实现这个能力 lookup_incident()
API 进程或服务之间如何访问能力 GET /incidents/INC-001
Tool Agent 可以看到并请求的能力契约 lookup_incident + schema

一个 Tool 可以由本地函数实现,也可以背后是一次 HTTP 调用。它的重点不是实现形式,而是向 Agent 暴露了一份带输入、输出、权限和副作用语义的能力说明。

这三者的区分不是为了术语整齐。当你说「给它加个工具」时,实际要做的是三件不同的事:写 Function(实现)、定 Tool(契约)、决定要不要走 API(部署形态)。把它们混成一个词,往往意味着其中一件没人负责。

ToolCall 和 ToolResult

内部协议只有两个对象,其余都是它们的衍生(contracts.py,可运行切片):

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]

class ToolResult(BaseModel):
    call_id: str
    tool_name: str
    tool_version: str | None = None
    ok: bool
    data: Any | None = None
    error: ToolError | None = None

三个容易被跳过的细节:

arguments 是 dict,不是关键字参数。
模型给的是「参数素材」,不是「已经对上 Python 签名的一串值」。字段名写错、多给一个键、值类型不对,在这个阶段都还只是数据问题。保持 dict 形态,才能在任何函数被调用之前判断它合不合法;如果直接 **arguments 展开,参数错误会以 TypeError 的形式从函数内部炸出来,那时你已经分不清这是谁的错。

call_id 由我们生成,不由模型提供。
模型没有义务给出一个稳定、唯一、适合做索引的编号。而重试、幂等、日志关联、结果回传都要靠同一个 call_id 串起来。到第 08 章会看到,一轮里出现两个相同工具的调用时,光有名字是根本区分不开的。

ok 和 error 并列存在。
一次失败的工具调用也是一个完整的 ToolResult。这让 Harness 可以用一套结构处理成功与失败,而不必让成功走返回值、失败走异常两条互不相通的路径。

ToolCall ≠ Tool Execution

这是本章最重要的一句话。画出来就是每次调用都要走过的那段距离:

sequenceDiagram
    participant M as 模型
    participant A as Adapter
    participant H as Harness
    participant T as 工具
    M->>A: 供应商格式的工具请求
    A->>H: 内部 ToolCall
    H->>H: 注册 / 参数 / 权限检查
    H->>T: 已验证的参数
    T-->>H: 返回值
    H->>H: 输出契约校验
    H-->>M: ToolResult(转成 tool 消息)

模型只参与首尾两端,中间那一段完全在你的进程里。它既看不到你自己追加了哪些检查,也没有办法跳过它们。

跑一次:四种确定性结果

python courses/foundation/tool-calling/course/project/examples/02_validated_dispatch.py

这个示例不调用模型,而是手写四个 ToolCall。这么做是为了先把视线从「模型这次说了什么」移开,只观察协议与执行边界。实际输出的第一段:

{
  "call_id": "call-valid",
  "tool_name": "lookup_incident",
  "tool_version": "1.0",
  "ok": true,
  "data": {
    "incident_id": "INC-001",
    "service": "auth-api",
    "severity": "high",
    "symptom": "登录接口持续返回 502",
    "evidence": ["auth-api timeout", "database pool exhausted"]
  },
  "error": null
}

另外三个都失败了,但失败原因各不相同——这正是要看的地方:

call-unknown   delete_prod     -> unknown_tool        (名字没注册)
call-invalid   lookup_incident -> invalid_arguments   (参数不合契约)
call-write     create_ticket   -> approval_required   (副作用未审批)

请在这三行上停一下。它们回答了一个很实际的问题:为什么工具调用的错误处理总是写不好? 因为这三件事表面上看都是「调用失败了」,恢复动作却毫无关系:

错误码 意味着 该做什么
unknown_tool 请求了一个不存在的名字 通常是模型幻觉或工具已下线,重试没有意义
invalid_arguments 名字对,参数不对 可以把错误回给模型让它改正,这是最有价值的一类
approval_required 名字和参数都对,只是现在不允许执行 去走审批流程,不是让模型换个名字重试

把它们压成一句 something went wrong,这三种能力会同时失去。

再看 tool_version:成功时是 "1.0",而 unknown_tool 时是 null。版本写在结果里而不是只写在日志里,是因为它决定了下游能不能安全使用这份数据——一个 v1 时代的解析器不该默默吞下 v2 的输出形状。

ToolError 的三个字段各管什么

{
  "code": "incident_not_found",
  "message": "没有找到该事件",
  "retryable": false
}

先记住四个主体

用户:    提出目标和身份
模型:    提出候选调用
Harness:校验、授权、执行、记录、停止
工具:    完成一个边界清楚的能力

注意「用户」和「模型」是分开的两行。这是工具调用里最容易混掉的一点:模型的身份不等于用户的权限。第 09 章会看到,ExecutionContext 里的 principal 来自运行时受信身份,而不是模型自述的那句「我是管理员」。

本章自测

如果事件不存在,这是模型的问题还是工具的业务结果?如果用户无权访问,这是工具函数抛出的异常还是策略拒绝?你应该能把这两组都分开——前者属于业务事实(可以回给模型解释),后者属于安全边界(不该因为模型多问几次就改变)。

本章检查点

下一章:先让模型真正提出一次工具请求

进入 keel 阅读