KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02. 先让模型真正提出一次工具请求 — keel 龙骨

上一章的请求是我们手写的。现在换成真实模型:给它一段自然语言和一组工具描述,让它自己决定要不要请求工具、请求哪一个、参数填什么。

上一章的请求是我们手写的。现在换成真实模型:给它一段自然语言和一组工具描述,让它自己决定要不要请求工具、请求哪一个、参数填什么。

这一章有两件事要做:接上真实模型跑一次,以及更重要的一件——看清哪一部分是我们的代码,哪一部分是模型的自由。

一次真实调用的消息流

ollama serve
ollama pull qwen3:8b
$env:OLLAMA_MODEL = "qwen3:8b"
python courses/foundation/tool-calling/course/project/examples/01_single_tool.py

跑之前先注意一件事:这段代码里没有任何一处假设模型会请求工具。它把两种可能都写出来了:

messages = [{"role": "user", "content": "查询 INC-001,并说明可能原因。"}]

# 第一次响应:可能有 assistant.tool_calls,也可能只有纯文本
assistant_message = response.message
messages.append(assistant_message)      # ← 整条 assistant 消息原样保存

supplier_calls = assistant_message.tool_calls or []
if not supplier_calls:
    return assistant_message.content    # 分支一:模型直接回答了

for supplier_call in supplier_calls:    # 分支二:模型请求了工具
    call = ToolCall(
        call_id=...,
        name=supplier_call.function.name,
        arguments=supplier_call.function.arguments,
    )
    ...
    messages.append({
        "role": "tool",
        "tool_name": call.name,
        "content": result.model_dump_json(),
    })

第二个模型请求才是「带证据的那一次」:它的 messages 里已经有了自己上一轮提出的工具请求和工具返回的事实。所以你的观察任务不是追求某次输出完全一致,而是找到代码如何处理这两条分支。

为什么必须保存整条 assistant 消息

一个常见的省事写法是:「我只把工具名和参数存下来,够了吧?」不够,而且会在两个地方出问题。

一是协议要求。 多数供应商 API 要求:如果 messages 里有 assistant 的工具请求,并且你没有把对应的 tool 结果按顺序补上,下一次请求会直接报 schema 错误。换句话说,assistant 那条消息是整个约定的一半,你删掉了一半。

二是回放要求。 只记工具名和参数,你不知道模型当时还说了什么文本。排查「为什么它选了这个工具」时,这条消息里的 thinking / content 往往就是答案。

这里还有一个细节值得留意:arguments 落到 Python 里有时已经是 dict,有时是 JSON 字符串,取决于供应商和 SDK 版本。所以不要假设它的类型,要在适配层统一。

适配层到底隔开了什么

ollama_adapter.py 的全部代码只有一件事:把「调供应商」藏起来。

class OllamaAdapter:
    def complete(self, messages, tools, *, think=True):
        return chat(model=model_name(), messages=messages, tools=tools,
                    think=think, stream=False)

它只承诺一件事:complete() 返回的对象带有 .message,而 .message 上有 .content 和 .tool_calls。除此之外,下游代码对供应商零知识。

这个承诺的价值可以用一个实验直接证明。假设你现在没有 Ollama——网络不通、没装、或者 CI 里跑不了。可以换一个照着脚本说话的假模型(scripted_adapter.py,可运行切片):

adapter = ScriptedAdapter([
    tool_step(("lookup_incident", {"incident_id": "INC-001"})),
    text_step("INC-001 是 auth-api 的高级别故障,证据指向数据库连接池耗尽。"),
])
run = ToolRunner(adapter, build_registry()).run("查询 INC-001")

第一步返回「我要调工具」,第二步返回最终文本。跑出来的结果与真实模型完全一致:

status = completed
  [tool.requested] run-f194c2aaf22c:step-0:call-0
  [tool.finished ] run-f194c2aaf22c:step-0:call-0
  第二轮模型看到的最后一条消息 role = tool
  这条消息携带的工具名 = lookup_incident

甚至可以验证两个适配器实现的是同一份接口:

  ScriptedAdapter.complete(self, messages, tools, *, think=True) -> ScriptedResponse
  OllamaAdapter.complete  (self, messages, tools, *, think=True) -> Any
  参数名一致:True

这一节的含义比「方便测试」大得多:既然换成一个假模型都能让下游照常工作,那么换成另一个供应商(OpenAI、Anthropic、自家网关)也只需要再写一个同样的 complete()。 校验、策略、执行、记录、停止这几层一行都不用动。这就是第 01 章那个 mermaid 图裡 Adapter 单独占一栏的原因。

SDK 自动生成 schema 解决了什么,没解决什么

Ollama SDK 能根据 Python 函数的签名和类型注解,自动生成给模型看的工具描述。它解决的是「怎样把能力告诉模型」,这是真实的工作量节省。

但它替代不了下面这些责任——每一条都在应用层:

  1. 工具名是否在允许列表;
  2. 参数是否通过内部 schema;
  3. 当前用户是否有权访问这个资源;
  4. 工具是否真正执行成功;
  5. 返回值是否满足输出契约;
  6. 这次运行是否应该继续。

第 1、3、6 条尤其容易被忽略,因为它们跟「函数能不能被调用」无关,只跟「这次该不该让它发生」有关。自动生成的是描述信任边界之外的东西;第 03 章要建立的,正是这条描述通往内部契约的桥。

顺带一个在第 04 章会变成主角的伏笔:自动推断只在「签名带有具体参数」时才有东西可推。一旦工具目标是远端服务、函数签名为泛化的 **kwargs,这份自动 schema 就没了来源,必须改由我们自己提供。

两个失败对照

把输入改成 INC-999,模型服务、协议、传输全都正常,工具依然会失败——但这是业务事实:

INC-999       -> incident_not_found     (工具正常执行,返回了「查无此事」)
模型不存在    -> model_unavailable      (整次运行失败,没有工具被调用)

这两者不能混。前者是可以回给模型让它解释或换工具的素材;后者意味着这一轮什么都没发生,重试或降级考虑的是另一套东西。

失败发生在哪一层,决定了三件事:要不要重试、要不要告诉用户、要不要人工介入。把这三件事答错的最常见原因,就是没分清「模型错了」和「工具说了事实」。

本章自测

模型这一轮没有请求工具,这是 bug 吗?不一定——它可能认为已知信息足够,这是模型的合法选择。但有一件事确定:你的代码不能依赖模型一定会请求工具。两条分支都写出来,才算处理完了这次调用。

本章检查点

下一章:一个工具契约究竟包含什么?

进入 keel 阅读