Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

工具调用与工作流

“帮我看看这次打印要多少钱,再把下午的预约取消掉。”

前两章的助手可以查资料、写回答。现在,用户希望它做点事情:读一条预约,算一个价格,甚至改变数据库中的状态。

这时,“我已经取消了”就不再是一句普通的回答。用户可能真的不来工坊了,而系统里的预约还占着位置。反过来,如果模型理解错了,却把另一场预约取消掉,也不能靠一句道歉把所有影响抹去。

这一章先给助手准备一套可靠的工具。我们继续使用虚构的青禾社区工坊,在本地 SQLite 文件里放几条教学预约。它们不连接真实账户,也不会发送消息或产生费用。你可以放心查看数据库怎样变化,再把这些方法带到自己的应用中。

1. 模型提出动作,程序负责执行

先看一个很小的工具请求:

{"tool": "quote", "arguments": {"grams": "25"}}

意思是调用报价工具,预估耗材为 25 克。模型生成这段文字时,并没有自动执行任何 Python 函数;应用还要解析它、检查参数,再决定是否调用工具。

因此,一次工具调用至少有三个不同的结果:

  1. 模型有没有选对工具、理解对参数?
  2. 程序有没有允许这次调用,并执行相应操作?
  3. 执行之后,环境实际变成了什么样?

这三件事不能互相替代。报价工具即使把 35 克算得分毫不差,也不能证明用户要求的 25 克已经处理正确;模型即使输出了完全合法的 JSON,也不能因此获得别人的预约权限。

图 1:工具调用跨过了“说话”与“做事”的边界。每次跨越都要留下可检查的请求和回执。

把环境状态记作 sts_t,模型提出的动作记作 ata_t。执行器接受动作后,才会产生:

st+1=T(st,at) s_{t+1}=T(s_t,a_t)

TT 是程序和环境共同决定的状态变化规则。查询可能不改变预约;取消则会把某条预约从 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 元,最终按实际耗材结算。设预估克数为 gg,先算:

q(g)=max⁡(8,  0.40g) q(g)=\max(8,\;0.40g)

其中 qq 的单位是元。于是,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:0012.5有效
B101(lin)10:3025有效
B102(lin)16:0050有效
B103(lin)15:0025已取消
B200(mei)14:0080有效

用户 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。如果用户真的要发起另一项操作,应使用新的请求键。

这叫幂等性。就这里关心的业务状态而言,重复执行同一请求不会增加新的副作用:

Fk(Fk(s))=Fk(s) F_k(F_k(s))=F_k(s)

这里 kk 是同一次请求的键,FkF_k 表示它对预约、事件与回执的作用。这个等式不是说网络只传输了一次,也不是说函数只被调用了一次;它说的是,重复请求不会把同一业务变化再做一遍。

图 2:客户端看到的“没收到”,不等于服务端的“没执行”。请求键让重试能找到之前的结果。

AWS 的幂等 API 实践说明讨论了调用方请求编号、重复请求和相同编号对应不同意图等问题。本例把这些想法缩小到一个本地数据库,便于亲手观察。

三件事必须一起落盘

只是加一张回执表还不够。假如先取消预约,程序崩溃后才来得及保存回执,下次仍不知道之前发生过什么。

因此,下面三件事在同一个数据库事务中完成:

  1. 修改预约状态与版本。
  2. 添加一条取消事件。
  3. 保存请求键、请求指纹和回执。

任意一步失败,整个事务回滚。全部成功,才提交。我们的 BEGIN IMMEDIATE 会先取得 SQLite 写事务;同一时刻的其他写入需要等待或处理锁冲突。SQLite 的事务文档解释了这些行为。

这只覆盖一个数据库内的变化。如果以后还要调用外部支付、发送邮件,就不能假设它们与 SQLite 自动一起回滚。可以先在事务内保存待发送任务,再由独立执行器投递,并让接收端也识别重复请求;本例没有实现这些外部操作。

重试也要有终点

本例只模拟传输超时,最多尝试两次。参数错误、无权限、提案过期、离预约太近,都不会靠原样重试变好。

真实网络调用还需要超时上限、退避等待和总预算。若每次请求独立地以概率 pp 遇到临时故障,两次都失败的概率是 p2p^2。但服务器整体宕机时,两次失败高度相关,不能拿这个简单乘法保证重试有效。

尤其不要在超时后换一个全新的请求键。那会让服务端把它当成另一次业务意图,恰好丢掉我们刚建立的保护。

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. 自己动手

  1. 用户要求 25 克,模型传了 35 克,报价工具返回 14.00 元。工具计算错了吗?整个任务正确吗?
  2. 12.5 克、20 克和 20.02 克各报价多少?最后一个结果怎样舍入?
  3. 为什么 booking 不让模型提供用户身份?
  4. 预约距离开始恰好两小时,准备提案后又过了一秒。提交时会怎样?
  5. 同一请求键改为另一个提案,应返回旧回执、执行新操作,还是拒绝?
  6. 把保存回执放在事务之外,可能出现什么故障?
  7. 为什么原请求重试时,可以在提案过期后返回之前的成功回执?
  8. 选做:在取消事件插入后、回执保存前注入数据库错误,检查预约状态、事件数与回执表。恢复故障后,用同一请求键再试一次。
展开答案与实验提示
  1. 对 35 克的计算正确,但任务参数错了。执行正确不代表意图理解正确。
  2. 分别为 8.00、8.00、8.01 元。20.02×0.40=8.00820.02\times0.40=8.008,按本例规则舍入到分。
  3. 身份是服务器已经验证的信息,不能让模型通过参数改写。编号格式正确也不代表有读取权限。
  4. 剩余时间为 7199 秒,返回 TOO_LATE,不修改预约。准备阶段的检查不能替代提交检查。
  5. 拒绝,返回 IDEMPOTENCY_CONFLICT。新的业务意图应有新的键,并重新满足确认条件。
  6. 可能已经修改预约,却没有留下能识别重试的回执;也可能留下回执,却没有完成相应业务变化。
  7. 返回的是已经发生的结果,不是再次执行过期操作。若之前没有成功回执,就必须执行当前有效期检查。
  8. 三类变化应一起回滚。仓库 scripts/test_tool_workflows.py 使用 SQLite 触发器模拟回执写入失败;移除故障后才能成功提交,最终仍只有一条取消事件。

我们现在有了真正能执行、能拒绝、也能解释结果的工具。下一章让模型在这些工具之间选择动作,看看一个Agent 的规划、行动与反馈循环究竟怎样运转。