问模型"订单 2001 一共多少钱",它可以给出一句像客服的回答,却不一定知道订单里实际有哪些商品。只要答案依赖业务数据,就需要让程序查询真实记录,再把结果交给模型组织语言。
这个教程面向会写基础 Python 的开发者。我们用 Ollama、SQLite 和一个查询函数,搭起一次工具调用的完整流程:模型识别需求,Python 校验并执行,模型根据返回值回答。示例采用虚构书店订单,不连接真实业务系统,也不需要云端模型账号。

先区分模型请求与程序执行
工具调用(Tool Calling)是模型生成的一份操作请求,例如"调用 query_order_amount,参数为 order_id=2001"。真正连接数据库、执行 SQL、返回结果的是 Python。模型看到的是工具名称、用途和参数结构,而不是函数内部的 SQL。1
这也是入门 Agent 值得先拆开学习的地方:模型负责选择动作,应用负责执行动作,并把结果放回对话。本例只完成一轮"请求工具---执行工具---返回结果",最多发出两次模型请求。它有一个工具,但不等于整个程序只调用一次模型。
先约定可检查的目标:订单 2001 包含两本单价 39.90 元的书和一个 12.50 元的书签,总计 92.30 元。我们同时检查工具有没有选对、订单号有没有取对,以及最终回答有没有改动金额。
准备可运行的本地环境
下面的命令使用 Windows PowerShell。需要事先安装 uv 和 Ollama;安装入口见文末官方资料。本文选择 Python 3.12 作为项目解释器,不要求追随最新 Python 版本。
先下载模型并建立独立项目:
ollama pull qwen3.5:4b
uv init --python 3.12 bookshop-agent
cd bookshop-agent
uv add ollama
qwen3.5:4b 在 Ollama 模型页标注了工具调用能力,但这不保证它每次都能正确选取工具。2 模型下载需要网络;下载完成后,本例使用本地 Ollama 服务推理。磁盘占用和运行内存是不同指标,选模型时还要考虑上下文长度和设备余量。
uv add 管理项目依赖,uv run 在项目环境中运行命令,因此不用手动激活虚拟环境。3 SQLite 属于 Python 标准库。项目里只需自己创建两个文件:init_db.py 和 agent.py,数据库文件会由初始化脚本生成。
用整数金额建立测试数据
先完成与模型无关的数据库部分。金额以"分"保存为整数,避免把二进制浮点数当成精确的货币表示。下面的脚本创建虚构订单,重复运行时保留已有表和记录,不删除文件。
保存为 init_db.py:
import sqlite3
from contextlib import closing
from pathlib import Path
DB = Path(file).with_name("bookshop.db")
with closing(sqlite3.connect(DB)) as conn:
execute("PRAGMA foreign_keys = ON")
executescript("""
CREATE TABLE IF NOT EXISTS orders (
order_id INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
name TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_fen INTEGER NOT NULL CHECK (price_fen >= 0)
);
""")
execute("INSERT OR IGNORE INTO orders VALUES (?)", (2001,))
executemany(
"INSERT OR IGNORE INTO items VALUES (?, ?, ?, ?, ?)",
[(1, 2001, "Python book", 2, 3990),
(2, 2001, "Bookmark", 1, 1250)],
)
commit()
print(f"Ready: {DB}")
运行 uv run python init_db.py,确认同目录出现 bookshop.db。如果自行修改了示例数据,INSERT OR IGNORE 不会覆盖原来的记录;请在新的练习目录初始化,或自行明确更新目标记录。
给模型一项范围明确的能力
查询函数只接受订单号,只返回订单金额。不要给第一个示例开放任意 SQL 或通用命令执行接口,否则学习工具调用的同时,还得处理更大的权限范围。
先把下面这一段保存为 agent.py,后面的两段依次追加到同一个文件中:
import json
import os
import sqlite3
import sys
from contextlib import closing
from pathlib import Path
from ollama import chat
DB = Path(file).with_name("bookshop.db")
MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:4b")
def query_order_amount(order_id: int) -> str:
"""Return the recorded order amount in CNY.
Args:
order_id: Positive integer order number.
"""
uri = DB.resolve().as_uri() + "?mode=ro"
with closing(sqlite3.connect(uri, uri=True)) as conn:
row = conn.execute("""
SELECT o.order_id,
COALESCE(SUM(i.quantity * i.price_fen), 0)
FROM orders AS o
LEFT JOIN items AS i ON i.order_id = o.order_id
WHERE o.order_id = ?
GROUP BY o.order_id
""", (order_id,)).fetchone()
if row is None:
data = {"error": "ORDER_NOT_FOUND", "order_id": order_id}
else:
total = row1
data = {
"order_id": row0, "currency": "CNY",
"amount": f"{total // 100}.{total % 100:02d}",
}
return json.dumps(data, ensure_ascii=False)
这里有三处值得保留:路径相对于脚本定位;连接使用只读模式,数据库不存在时不会偷偷创建空库;订单号通过 ? 占位符传入,不拼接 SQL。closing 会明确关闭连接,避免把事务上下文误当成连接关闭机制。
LEFT JOIN 用来区分"不存在的订单"和"存在但没有商品的订单"。前者返回错误,后者金额为 0.00。金额返回为字符串,是为了保留两位小数;真实业务还应明确优惠、运费、税费和退款的计价规则。
在应用侧校验工具请求
函数的类型注解会帮助 SDK 生成参数说明,但它不会自动阻止错误类型进入函数。下面把名称白名单、字段检查和整数检查放在执行之前。继续追加到 agent.py:
def execute_tool(call) -> str:
if call.function.name != "query_order_amount":
raise ValueError("Unknown tool")
args = call.function.arguments
if not isinstance(args, dict) or set(args) != {"order_id"}:
raise ValueError("Expected only order_id")
order_id = args"order_id"
if type(order_id) is not int or order_id <= 0:
raise ValueError("order_id must be a positive integer")
print("Tool request:", call.function.name, args)
result = query_order_amount(order_id)
print("Tool result:", result)
return result
这里使用 type(order_id) is int,会拒绝字符串、浮点数和布尔值。Python 中 bool 是 int 的子类,仅用 isinstance(value, int) 会把 True 也当作合法整数。
对这个练习来说,遇到非法请求直接停止就够了。后续需要自动纠错时,可以把结构化校验错误返回给模型,但应设置重试上限。也不要从模型返回的名称中动态查找或执行任意函数。
把执行结果放回对话
最后追加入口函数。第一轮允许模型请求工具,第二轮只要求它根据已取得的结果答复:
def main():
question = " ".join(sys.argv1:) or "订单 2001 一共多少钱?"
messages = [
{"role": "system", "content": (
"Answer in Chinese. Use query_order_amount for order amounts. "
"Do not invent shop facts. Preserve the returned amount and "
"currency. If data is unavailable, explain that limitation."
)},
{"role": "user", "content": question},
]
response = chat(model=MODEL, messages=messages,
tools=query_order_amount)
calls = response.message.tool_calls or \[\]
if len(calls) != 1:
raise RuntimeError("Expected exactly one tool call")
result = execute_tool(calls0)
append(response.message)
append({
"role": "tool", "tool_name": calls0.function.name,
"content": result,
})
final = chat(model=MODEL, messages=messages)
if final.message.tool_calls or not final.message.content.strip():
raise RuntimeError("Expected a final text answer")
print("Agent:", final.message.content)
if name == "main":
main()
Ollama Python SDK 支持把函数直接放入 tools,根据函数签名和文档字符串生成工具描述。1 我们保留第一轮的 assistant 消息,让模型在第二轮看到自己发出的请求,再用 tool 消息提供执行结果。
这个入口刻意规定只能调用一次工具。它适合观察一次完整交互,不是通用多步 Agent。第二轮不再提供工具列表,也没有无限循环;如果以后支持多步任务,应另外限制轮次、超时和累计执行成本。
验收时分别检查数据和模型行为
运行 uv run python agent.py。正常请求应产生下面的工具记录,最后一句为示意,实际模型措辞可能不同:
Tool request: query_order_amount {'order_id': 2001}
Tool result: {"order_id": 2001, "currency": "CNY", "amount": "92.30"}
Agent: 订单 2001 的总金额是 92.30 元人民币。
工具记录正确,只能证明 Python 查到了正确值,不能保证模型一定忠实转述。如果金额不能容许任何偏差,更可靠的做法是由程序根据结构化结果直接生成金额句子,或在输出前核对模型答案。模型可以补充解释,但结算金额应以业务系统为准。
接着运行两类失败场景:
uv run python agent.py "订单 9999 一共多少钱?"
uv run python agent.py "订单 2001 什么时候送到?"
第一条应查得 ORDER_NOT_FOUND,最终回答应说明订单不存在。第二条超出了工具能力:金额数据没有物流字段。模型可能不调用工具而触发入口异常,也可能调用金额工具后解释缺少物流数据;这两种结果都不能当成已经完成配送查询。如果它猜出到货日期,验收应判为失败。
排错时按结果发生的位置检查:
无法连接 Ollama:先确认应用或服务正在运行,再用 ollama list 检查模型是否已下载。
unable to open database file:确认先运行了初始化脚本,数据库与 agent.py 位于同一目录,且进程有读权限。
Expected exactly one tool call:检查问题是否属于金额查询,再查看模型返回的工具请求;系统提示无法代替执行侧检查。
order_id must be a positive integer:参数类型不合格,应修正请求或采用有上限的重试。
工具结果正确但回答错误:优先改用确定性的结果模板,再评估是否需要更换模型。
更换已安装且支持工具调用的模型,可以在当前 PowerShell 会话中设置 $env:OLLAMA_MODEL = "你的模型名称"。比较模型时,记录它是否选对工具和参数,而不仅仅是最终句子是否流畅。
从练习脚本走向真实服务
上线前需要增加用户身份和订单归属校验。只读 SQL 与工具白名单能够缩小操作范围,但不能证明提问者有权查看某个订单;业务权限必须由可信应用上下文决定,不能相信模型自报的用户身份。
如果希望多人使用或长期提供查询,需要把脚本封装成服务,并补上进程管理、请求超时、日志脱敏、鉴权和访问限制。应用与推理服务可以分别部署,模型选择则按内存或显存、并发和可接受延迟评估。
迁移到远程环境时,可以评估 Hostease 或其他服务器资源,并按应用负载与模型推理需求确认配置。本地跑通只是迁移的起点,服务器选型不会替代权限控制和输出验收。
下一步建议先增加第二个只读工具,例如查询订单状态,然后用"金额"和"配送"两类问题检查模型是否能选对能力。把单工具流程验证清楚,再引入多步调度,会更容易判断错误来自数据、执行还是模型。