工具调用与工作流
“帮我看看这次打印要多少钱,再把下午的预约取消掉。”
前两章的助手可以查资料、写回答。现在,用户希望它做点事情:读一条预约,算一个价格,甚至改变数据库中的状态。
这时,“我已经取消了”就不再是一句普通的回答。用户可能真的不来工坊了,而系统里的预约还占着位置。反过来,如果模型理解错了,却把另一场预约取消掉,也不能靠一句道歉把所有影响抹去。
这一章先给助手准备一套可靠的工具。我们继续使用虚构的青禾社区工坊,在本地 SQLite 文件里放几条教学预约。它们不连接真实账户,也不会发送消息或产生费用。你可以放心查看数据库怎样变化,再把这些方法带到自己的应用中。
1. 模型提出动作,程序负责执行
先看一个很小的工具请求:
{"tool": "quote", "arguments": {"grams": "25"}}
意思是调用报价工具,预估耗材为 25 克。模型生成这段文字时,并没有自动执行任何 Python 函数;应用还要解析它、检查参数,再决定是否调用工具。
因此,一次工具调用至少有三个不同的结果:
- 模型有没有选对工具、理解对参数?
- 程序有没有允许这次调用,并执行相应操作?
- 执行之后,环境实际变成了什么样?
这三件事不能互相替代。报价工具即使把 35 克算得分毫不差,也不能证明用户要求的 25 克已经处理正确;模型即使输出了完全合法的 JSON,也不能因此获得别人的预约权限。
图 1:工具调用跨过了“说话”与“做事”的边界。每次跨越都要留下可检查的请求和回执。
把环境状态记作 ,模型提出的动作记作 。执行器接受动作后,才会产生:
是程序和环境共同决定的状态变化规则。查询可能不改变预约;取消则会把某条预约从 active 改为 cancelled。模型预测的是动作文字,真正修改状态的是执行器。
2. 一个好接口,应该让错误容易被发现
工具不要太万能
“执行任意 SQL”听起来很灵活,却把表名、查询范围、权限和写入操作都交给了模型。对于这个工坊,几个小工具已经够用:
| 工具 | 模型提供的参数 |
|---|---|
search | 查询词 query |
quote | 克数文本 grams |
booking | 预约编号 id |
prepare_cancel | 预约编号 id |
查询资料会返回当前公开片段与来源,报价会返回金额、单位和规则版本,查询预约则只返回当前用户可查看的记录。这三个工具都不改变预约。
prepare_cancel 会保存一份待确认的取消提案,预约本身仍保持原状。
确认提案和最终提交由应用处理,不在模型可以调用的工具名单中。读到“我确认了”这几个字,也不会自动给提案盖章。确认界面需要展示具体预约和影响,再把用户确认的对象传给程序。
工具应暴露业务动作,而不是把所有底层能力都装进去。查预约不需要任意文件路径,报价不需要网络访问,取消也不需要一段由模型编写的 SQL。
先检查形状,再检查含义
第 31 章已经讨论过 JSON 契约。工具输入同样要逐层检查:
- 字段是否恰好是接口要求的字段?
quote不接受额外的owner或approved。 - 类型对不对?本例克数是字符串
"25",不是布尔值,也不是任意对象。 - 值在允许的范围内吗?克数必须大于 0、不超过 1000,最多两位小数。
- 在当前状态下,这个操作还可以做吗?预约可能已取消,或者已经离开始时间太近。
不要把服务器身份塞进模型需要填写的参数里。我们的 actor="lin" 来自应用建立的会话;模型只能提供预约编号,不能通过添加 "actor":"mei" 换成另一个人。
回执也要有稳定结构。一次失败可以返回:
{"ok": false, "error": "NOT_FOUND_OR_DENIED"}
本例对“没有这条预约”和“无权查看这条预约”使用同一个错误,避免顺便告诉调用者某个编号属于别人。具体数据库错误、内部路径或其他用户资料,不需要原封不动地送回模型。
3. 把计算交给计算工具
工坊资料规定:3D 打印每克 0.40 元,最低 8 元,最终按实际耗材结算。设预估克数为 ,先算:
其中 的单位是元。于是,25 克报价 10 元;12.5 克虽然按克数只算出 5 元,仍按最低价报 8 元。这个结果是预估,不是已经扣款,也不是实际耗材的最终账单。
金额还需要约定小数处理方式。本例把结果按四舍五入保留到分:
amount = max(Decimal("8"), Decimal(grams) * Decimal("0.40"))
amount = amount.quantize(Decimal(".01"), rounding=ROUND_HALF_UP)
Decimal 从十进制字符串建立数值,适合清楚表达这里的金额规则。别先把字符串转成二进制浮点数,再指望它恢复原来的十进制输入。Python 的 Decimal 文档说明了这些表示与舍入方式。
为什么还要限制输入格式?因为 NaN、无穷大、负数或特别长的数字,都不是本工具要接收的耗材预估。格式检查和数值检查配合起来,才构成完整接口。
报价回执还带着 estimate_only=true、货币单位和 price:1 来源。把这些信息一起交付,用户才知道这个数字表示什么。实际系统还应让价格配置与发布的规则同步更新;本例把它们固定在同一教学版本,不让模型临时改价。
4. 数据库里的状态,不能靠聊天历史代替
本例把时钟固定在北京时间 2026 年 10 月 20 日 10:00。这样,今天和下周运行脚本都会得到相同结果;真实服务应使用可信的服务端时钟。
下面的开始时间都在当天,克数是预估用量。
| 预约(用户) | 时间 | 克数 | 状态 |
|---|---|---|---|
| B100(lin) | 14:00 | 12.5 | 有效 |
| B101(lin) | 10:30 | 25 | 有效 |
| B102(lin) | 16:00 | 50 | 有效 |
| B103(lin) | 15:00 | 25 | 已取消 |
| B200(mei) | 14:00 | 80 | 有效 |
用户 lin 查询 B100 时,程序使用这样的条件:
row = db.execute(
"SELECT * FROM bookings WHERE id=? AND owner=?",
(booking_id, actor),
).fetchone()
? 是参数占位符。SQL 结构由程序写好,具体值由数据库驱动绑定;把输入直接拼进 SQL 字符串,会混淆数据与查询语法。参数绑定的用法见 Python sqlite3 文档。
这里的两个条件各做一件事:编号定位记录,用户身份限制可读范围。只检查编号格式为 B 加三位数字,并不能解决权限问题。
先形成提案,再确认具体变化
取消 B100 时,prepare_cancel 会检查归属、当前状态和时间条件,然后保存一份提案。提案绑定:
- 当前用户和目标预约;
- 预约当前的版本号;
- 五分钟后的失效时间;
- 尚未确认这一事实。
版本号可以理解成记录的修订次数。用户看见提案时预约是第 1 版,等他确认时却已被另一个操作改成第 2 版,原提案就不能直接照办。程序返回 STALE,重新读取并展示变化。
本例的规则是至少提前两小时才允许自行免费取消。因此,恰好剩 7200 秒可以;只剩 7199 秒就需要联系服务台。既要在准备提案时检查,也要在最终提交时再检查,因为等待确认期间,时间仍会流逝。
提案编号不是通行证。知道编号的人仍要通过归属检查,确认记录也要与同一用户、同一预约相符。我们用整数编号只是为了让实验容易重放,并没有把“别人猜不到编号”当作权限机制。
5. 回执丢了,究竟要不要再试一次
最棘手的超时,不是服务器什么都没做,而是服务器已经做完,客户端却没收到回答。
想象一下:数据库已经取消了 B100,响应在返回途中丢失。客户端看到超时,以为没有成功,又提交了一次。如果每次都新增一条取消事件,后续通知、统计或退款流程就可能重复。
给一次业务意图一个稳定编号
本例让应用为这次操作分配请求键 cancel-B100-1。后续重试继续带上同一个键。服务端把“用户、请求键”作为查找范围,并同时保存原请求的指纹和成功回执。
再次收到同一用户、同一键、同一请求时,返回原回执;键相同、提案却变了,就返回 IDEMPOTENCY_CONFLICT。如果用户真的要发起另一项操作,应使用新的请求键。
这叫幂等性。就这里关心的业务状态而言,重复执行同一请求不会增加新的副作用:
这里 是同一次请求的键, 表示它对预约、事件与回执的作用。这个等式不是说网络只传输了一次,也不是说函数只被调用了一次;它说的是,重复请求不会把同一业务变化再做一遍。
图 2:客户端看到的“没收到”,不等于服务端的“没执行”。请求键让重试能找到之前的结果。
AWS 的幂等 API 实践说明讨论了调用方请求编号、重复请求和相同编号对应不同意图等问题。本例把这些想法缩小到一个本地数据库,便于亲手观察。
三件事必须一起落盘
只是加一张回执表还不够。假如先取消预约,程序崩溃后才来得及保存回执,下次仍不知道之前发生过什么。
因此,下面三件事在同一个数据库事务中完成:
- 修改预约状态与版本。
- 添加一条取消事件。
- 保存请求键、请求指纹和回执。
任意一步失败,整个事务回滚。全部成功,才提交。我们的 BEGIN IMMEDIATE 会先取得 SQLite 写事务;同一时刻的其他写入需要等待或处理锁冲突。SQLite 的事务文档解释了这些行为。
这只覆盖一个数据库内的变化。如果以后还要调用外部支付、发送邮件,就不能假设它们与 SQLite 自动一起回滚。可以先在事务内保存待发送任务,再由独立执行器投递,并让接收端也识别重复请求;本例没有实现这些外部操作。
重试也要有终点
本例只模拟传输超时,最多尝试两次。参数错误、无权限、提案过期、离预约太近,都不会靠原样重试变好。
真实网络调用还需要超时上限、退避等待和总预算。若每次请求独立地以概率 遇到临时故障,两次都失败的概率是 。但服务器整体宕机时,两次失败高度相关,不能拿这个简单乘法保证重试有效。
尤其不要在超时后换一个全新的请求键。那会让服务端把它当成另一次业务意图,恰好丢掉我们刚建立的保护。
6. 工作流:先把已经知道的步骤写清楚
“查询预约中的克数,再报价”有一条清晰路线:
booking = service.call("booking", {"id": "B100"})
if booking["ok"]:
quote = service.call("quote", {"grams": booking["estimated_grams"]})
这里不需要模型猜第二步是什么。程序明确知道,只有查询成功,才能读取 estimated_grams 并继续报价;查询失败就进入错误分支。
这就是一种工作流:步骤、条件与数据传递由程序安排。用户可以先在表单里选择任务类型,也可以让模型提出类型后再检查,但别把这两种输入难度混为一谈。
搜索也能成为工作流的一步。本章的 search 沿用前章的公开资料过滤和 BM25,接受新的查询文本,返回两个得分为正的候选。它没有拿固定问题的嵌入向量表冒充任意问题接口,也没有因为“搜到了”就宣布资料足够。
什么时候需要更灵活的安排?例如用户要先了解多项设备条件,再根据中途发现的信息决定接下来查什么。那时,程序可能让模型反复选择下一步。这是下一章的主题。无论步骤由谁选择,本章的参数、权限、确认和事务检查仍然有效。
7. 运行一次,再故意丢掉回执
在仓库根目录执行,Python 3.9 或更高版本即可:
python3 src/examples/workshop_tools.py
脚本会创建临时数据库,完成查询、报价、失败分支、确认和重试实验,结束后清理临时目录。若单独下载,需要同时保留 脚本、prompt_contracts.py、retrieval_qa.py 和相邻 data/workshop-documents.json;更方便的办法是使用整份仓库。
展开完整工具实现
"""第 33 章:真实 SQLite 状态、十进制报价、受控取消与幂等回执。只用标准库。"""
from contextlib import closing, contextmanager
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
import json
from pathlib import Path
import re
import sqlite3
from tempfile import TemporaryDirectory
from prompt_contracts import fingerprint, read_json
from retrieval_qa import bm25, chunk_documents
CLOCK = datetime(2026, 10, 20, 10, tzinfo=timezone(timedelta(hours=8)))
NOW = int(CLOCK.timestamp())
FIXTURES = [
("B100", "lin", NOW+4*3600, "12.5", "active", 1),
("B101", "lin", NOW+30*60, "25", "active", 1),
("B102", "lin", NOW+6*3600, "50", "active", 1),
("B103", "lin", NOW+5*3600, "25", "cancelled", 2),
("B200", "mei", NOW+4*3600, "80", "active", 1),
]
class ToolError(Exception):
"""给调用方的稳定错误码;不直接暴露数据库异常或其他人的数据。"""
def initialize(path):
if Path(path).exists():
raise FileExistsError("演示初始化只允许新数据库")
with closing(sqlite3.connect(path)) as db:
db.executescript("""
CREATE TABLE bookings(
id TEXT PRIMARY KEY, owner TEXT, starts INTEGER, grams TEXT,
status TEXT, version INTEGER);
CREATE TABLE proposals(
id INTEGER PRIMARY KEY, actor TEXT, booking TEXT,
version INTEGER, expires INTEGER, approved INTEGER DEFAULT 0);
CREATE TABLE receipts(
actor TEXT, request_key TEXT, payload TEXT, response TEXT,
PRIMARY KEY(actor, request_key));
CREATE TABLE events(
id INTEGER PRIMARY KEY, actor TEXT, booking TEXT, request_key TEXT);
""")
db.executemany("INSERT INTO bookings VALUES(?,?,?,?,?,?)", FIXTURES)
db.commit()
class Workshop:
def __init__(self, path, actor="lin", now=NOW):
# actor 来自已认证会话;now 是可替换的教学时钟,均不接受模型参数。
self.actor, self.now = actor, now
self.db = sqlite3.connect(path, isolation_level=None)
self.db.row_factory = sqlite3.Row
self.chunks = chunk_documents(read_json("workshop-documents.json"))
def close(self):
self.db.close()
@contextmanager
def transaction(self):
self.db.execute("BEGIN IMMEDIATE")
try:
yield
self.db.execute("COMMIT")
except BaseException:
if self.db.in_transaction:
self.db.execute("ROLLBACK")
raise
def owned(self, booking_id):
row = self.db.execute(
"SELECT * FROM bookings WHERE id=? AND owner=?",
(booking_id, self.actor)).fetchone()
if row is None:
raise ToolError("NOT_FOUND_OR_DENIED")
return row
def eligible(self, row):
if row["status"] == "cancelled":
raise ToolError("ALREADY_CANCELLED")
if row["starts"]-self.now < 2*3600:
raise ToolError("TOO_LATE")
def booking(self, booking_id):
row = self.owned(booking_id)
start = datetime.fromtimestamp(row["starts"], CLOCK.tzinfo).isoformat()
return dict(kind="booking", id=row["id"], starts=start,
estimated_grams=row["grams"], status=row["status"],
version=row["version"])
def quote(self, grams):
if (not isinstance(grams, str)
or not re.fullmatch(r"(?:0|[1-9]\d{0,3})(?:\.\d{1,2})?", grams)):
raise ToolError("BAD_GRAMS")
weight = Decimal(grams)
if not 0 < weight <= 1000:
raise ToolError("BAD_GRAMS")
amount = max(Decimal("8"), weight*Decimal("0.40"))
return dict(kind="quote", grams=grams,
amount=str(amount.quantize(Decimal(".01"), rounding=ROUND_HALF_UP)),
currency="CNY", estimate_only=True, source="price:1",
policy_version="2026-09-01")
def search(self, query):
if not isinstance(query, str) or not 1 <= len(query.strip()) <= 100:
raise ToolError("BAD_QUERY")
hits = [dict(id=c["id"], version=c["version"], text=c["text"])
for c, score in bm25(query, self.chunks)[:2] if score > 0]
return dict(kind="search", hits=hits)
def prepare_cancel(self, booking_id):
with self.transaction():
row = self.owned(booking_id)
self.eligible(row)
cursor = self.db.execute(
"INSERT INTO proposals(actor,booking,version,expires) VALUES(?,?,?,?)",
(self.actor, booking_id, row["version"], self.now+300))
proposal_id = cursor.lastrowid
return dict(kind="proposal", proposal=proposal_id, booking=booking_id,
version=row["version"], expires_at=self.now+300,
effect="取消预约,不扣费", requires_confirmation=True)
def proposal(self, proposal_id):
if type(proposal_id) is not int:
raise ToolError("BAD_PROPOSAL")
row = self.db.execute(
"SELECT * FROM proposals WHERE id=? AND actor=?",
(proposal_id, self.actor)).fetchone()
if row is None:
raise ToolError("NOT_FOUND_OR_DENIED")
return row
def approve(self, proposal_id, expected_booking):
"""由可信确认界面调用;不在模型工具表中。界面展示并绑定具体预约。"""
with self.transaction():
proposal = self.proposal(proposal_id)
if proposal["booking"] != expected_booking:
raise ToolError("WRONG_CONFIRMATION_TARGET")
if self.now >= proposal["expires"]:
raise ToolError("EXPIRED")
row = self.owned(proposal["booking"])
if row["version"] != proposal["version"]:
raise ToolError("STALE")
self.eligible(row)
self.db.execute("UPDATE proposals SET approved=1 WHERE id=?", (proposal_id,))
def commit_cancel(self, proposal_id, request_key):
"""由应用在确认后调用;同一用户的同一请求键只能对应同一取消提案。"""
if (type(proposal_id) is not int or not isinstance(request_key, str)
or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", request_key)):
raise ToolError("BAD_REQUEST")
payload = fingerprint(dict(operation="cancel", proposal=proposal_id))
with self.transaction():
saved = self.db.execute(
"SELECT payload,response FROM receipts WHERE actor=? AND request_key=?",
(self.actor, request_key)).fetchone()
if saved:
if saved["payload"] != payload:
raise ToolError("IDEMPOTENCY_CONFLICT")
return json.loads(saved["response"])
proposal = self.proposal(proposal_id)
if not proposal["approved"]:
raise ToolError("NOT_CONFIRMED")
if self.now >= proposal["expires"]:
raise ToolError("EXPIRED")
row = self.owned(proposal["booking"])
if row["version"] != proposal["version"]:
raise ToolError("STALE")
self.eligible(row)
changed = self.db.execute(
"UPDATE bookings SET status='cancelled',version=version+1 "
"WHERE id=? AND owner=? AND version=?",
(row["id"], self.actor, row["version"])).rowcount
if changed != 1:
raise ToolError("STALE")
event = self.db.execute(
"INSERT INTO events(actor,booking,request_key) VALUES(?,?,?)",
(self.actor, row["id"], request_key)).lastrowid
response = dict(kind="cancellation", booking=row["id"],
status="cancelled", version=row["version"]+1, event=event)
self.db.execute("INSERT INTO receipts VALUES(?,?,?,?)",
(self.actor, request_key, payload,
json.dumps(response, ensure_ascii=False, sort_keys=True)))
return response
def call(self, tool, arguments):
"""模型入口:精确字段、类型和工具白名单;禁止注入身份、SQL 或确认结果。"""
fields = {"search": "query", "quote": "grams",
"booking": "id", "prepare_cancel": "id"}
try:
if not isinstance(tool, str) or tool not in fields:
raise ToolError("UNKNOWN_TOOL")
key = fields[tool]
if not isinstance(arguments, dict) or set(arguments) != {key}:
raise ToolError("BAD_FIELDS")
argument = arguments[key]
if not isinstance(argument, str):
raise ToolError("BAD_TYPE")
if key == "id" and not re.fullmatch(r"B[0-9]{3}", argument):
raise ToolError("BAD_ID")
result = getattr(self, tool)(argument)
return dict(ok=True, **result)
except ToolError as error:
return dict(ok=False, error=str(error))
def state(self):
"""仅供本地测试/审计读取完整快照,未暴露给模型。"""
rows = self.db.execute("SELECT id,status,version FROM bookings ORDER BY id").fetchall()
return dict(bookings=[dict(row) for row in rows],
events=self.db.execute("SELECT count(*) FROM events").fetchone()[0])
def cancel_with_retry(service, proposal, key, lose_first_response=False):
"""模拟回执在提交之后丢失;只重试传输超时,最多两次,始终使用原请求键。"""
for attempt in range(2):
try:
receipt = service.commit_cancel(proposal, key)
if lose_first_response and attempt == 0:
raise TimeoutError("服务端已提交,但回执未到达客户端")
return receipt, attempt+1
except TimeoutError:
if attempt == 1:
raise
def main():
with TemporaryDirectory() as directory:
path = Path(directory)/"workshop.sqlite"
initialize(path)
service = Workshop(path)
print("教学时钟:", CLOCK.isoformat())
print("25 克报价:", service.call("quote", {"grams": "25"})["amount"], "元")
print("12.5 克报价:", service.call("quote", {"grams": "12.5"})["amount"], "元")
print("开放时间来源:", service.search("工坊周三几点闭馆")["hits"][0]["id"])
print("B100 初始状态:", service.booking("B100")["status"])
print("查询他人的预约:", service.call("booking", {"id": "B200"})["error"])
print("不足两小时:", service.call("prepare_cancel", {"id": "B101"})["error"])
proposal = service.call("prepare_cancel", {"id": "B100"})["proposal"]
try:
service.commit_cancel(proposal, "cancel-B100-1")
except ToolError as error:
print("未确认就提交:", error)
service.approve(proposal, expected_booking="B100") # 模拟人点击明确的确认按钮
receipt, attempts = cancel_with_retry(
service, proposal, "cancel-B100-1", lose_first_response=True)
print("丢失首次回执后的请求次数:", attempts)
print("最终状态:", receipt["status"], ";取消事件数:", service.state()["events"])
service.close()
restored = Workshop(path, now=NOW+600)
again = restored.commit_cancel(proposal, "cancel-B100-1")
print("重开数据库、提案过期后,原请求仍返回原回执:", again == receipt)
restored.close()
if __name__ == "__main__":
main()
实际输出为:
教学时钟: 2026-10-20T10:00:00+08:00
25 克报价: 10.00 元
12.5 克报价: 8.00 元
开放时间来源: hours:1
B100 初始状态: active
查询他人的预约: NOT_FOUND_OR_DENIED
不足两小时: TOO_LATE
未确认就提交: NOT_CONFIRMED
丢失首次回执后的请求次数: 2
最终状态: cancelled ;取消事件数: 1
重开数据库、提案过期后,原请求仍返回原回执: True
最后一行尤其值得看一眼。数据库重新打开时,提案已经过期,但原请求早已成功。程序先查到保存的回执,再返回那次成功的结果;它没有在过期后发起一次新的取消。
这里的人类确认由测试驱动明确模拟:调用 approve,并指定已经核对的 B100。它不是模型说了一句“已确认”,也不是一个真正做好的网页按钮。接入界面时,还需要完成登录、会话验证和防止伪造确认请求的处理。
8. 自己动手
- 用户要求 25 克,模型传了 35 克,报价工具返回 14.00 元。工具计算错了吗?整个任务正确吗?
- 12.5 克、20 克和 20.02 克各报价多少?最后一个结果怎样舍入?
- 为什么
booking不让模型提供用户身份? - 预约距离开始恰好两小时,准备提案后又过了一秒。提交时会怎样?
- 同一请求键改为另一个提案,应返回旧回执、执行新操作,还是拒绝?
- 把保存回执放在事务之外,可能出现什么故障?
- 为什么原请求重试时,可以在提案过期后返回之前的成功回执?
- 选做:在取消事件插入后、回执保存前注入数据库错误,检查预约状态、事件数与回执表。恢复故障后,用同一请求键再试一次。
展开答案与实验提示
- 对 35 克的计算正确,但任务参数错了。执行正确不代表意图理解正确。
- 分别为 8.00、8.00、8.01 元。,按本例规则舍入到分。
- 身份是服务器已经验证的信息,不能让模型通过参数改写。编号格式正确也不代表有读取权限。
- 剩余时间为 7199 秒,返回
TOO_LATE,不修改预约。准备阶段的检查不能替代提交检查。 - 拒绝,返回
IDEMPOTENCY_CONFLICT。新的业务意图应有新的键,并重新满足确认条件。 - 可能已经修改预约,却没有留下能识别重试的回执;也可能留下回执,却没有完成相应业务变化。
- 返回的是已经发生的结果,不是再次执行过期操作。若之前没有成功回执,就必须执行当前有效期检查。
- 三类变化应一起回滚。仓库
scripts/test_tool_workflows.py使用 SQLite 触发器模拟回执写入失败;移除故障后才能成功提交,最终仍只有一条取消事件。
我们现在有了真正能执行、能拒绝、也能解释结果的工具。下一章让模型在这些工具之间选择动作,看看一个Agent 的规划、行动与反馈循环究竟怎样运转。