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 函数的签名和类型注解,自动生成给模型看的工具描述。它解决的是「怎样把能力告诉模型」,这是真实的工作量节省。
但它替代不了下面这些责任——每一条都在应用层:
- 工具名是否在允许列表;
- 参数是否通过内部 schema;
- 当前用户是否有权访问这个资源;
- 工具是否真正执行成功;
- 返回值是否满足输出契约;
- 这次运行是否应该继续。
第 1、3、6 条尤其容易被忽略,因为它们跟「函数能不能被调用」无关,只跟「这次该不该让它发生」有关。自动生成的是描述信任边界之外的东西;第 03 章要建立的,正是这条描述通往内部契约的桥。
顺带一个在第 04 章会变成主角的伏笔:自动推断只在「签名带有具体参数」时才有东西可推。一旦工具目标是远端服务、函数签名为泛化的 **kwargs,这份自动 schema 就没了来源,必须改由我们自己提供。
两个失败对照
把输入改成 INC-999,模型服务、协议、传输全都正常,工具依然会失败——但这是业务事实:
INC-999 -> incident_not_found (工具正常执行,返回了「查无此事」)
模型不存在 -> model_unavailable (整次运行失败,没有工具被调用)
这两者不能混。前者是可以回给模型让它解释或换工具的素材;后者意味着这一轮什么都没发生,重试或降级考虑的是另一套东西。
失败发生在哪一层,决定了三件事:要不要重试、要不要告诉用户、要不要人工介入。把这三件事答错的最常见原因,就是没分清「模型错了」和「工具说了事实」。
本章自测
模型这一轮没有请求工具,这是 bug 吗?不一定——它可能认为已知信息足够,这是模型的合法选择。但有一件事确定:你的代码不能依赖模型一定会请求工具。两条分支都写出来,才算处理完了这次调用。
本章检查点
- 一次真实调用里,模型可能直接回答也可能请求工具,两条分支都要处理;
- 必须保存整条 assistant 消息:协议要求按顺序配对,回放要求知道它还说了什么;
- 适配层只承诺
.message.content与.message.tool_calls,下游对供应商零依赖; - 正因为如此,换成脚本化假模型(或换供应商)时,下游一行都不用改;
- SDK 生成的 schema 只负责「把能力告诉模型」,不负责白名单、权限、成功判定与停止;
incident_not_found是工具的业务结果,model_unavailable是运行级失败,两者恢复动作不同。