AI Hero Club

AI 智能体工程 · 第 4 章

已自行标记完成0 / 8

仅保存在当前浏览器;完成标记不代表通过项目验收。

本章提纲 8 个小节
全课目录 8 章
完整课程计划 ↗
AI 智能体工程 / 课程计划 ↗

CHAPTER 04 / 8建议学习 120 分钟

重试、幂等与提交后失联

库存已经扣减却没收到回复,再试一次怎样避免重复扣减?

本章要解决的 3 个问题
  • 区分请求、尝试和业务效果的身份
  • 推导效果与回执同事务的必要性
  • 实际注入回复丢失并恢复而不重复扣减
课程项目 ZIP ↓

重试不是再思考一次

第三章保证调用次数有限,但失败后的再次调用仍可能造成重复效果。先定义业务意图为一次任务希望预留某器材某数量;尝试是传输或执行该意图的一次机会;效果是库存真正扣减。一个意图可能经历多次尝试,但正确的幂等协议让它最多产生一次同样效果。读取文档通常没有库存写入,预留则有,所以不能给所有工具一套无差别的“超时就重试”。本章只讨论当前本地单数据库事务,不承诺远程服务自动恰好一次。

客户端没收到成功可能有多种原因:请求尚未执行、执行中失败并回滚、事务已提交但回复丢失、回复已到但客户端来不及保存。后两种与前两种在客户端都可能表现为没有结果。因此“捕获异常就再扣一次”不是恢复策略。项目的 lost_reply 故障在库存与回执已经提交后抛出可重试异常,crash_after_commit 则直接模拟中断,让 pending 留在持久化状态;两者都通过实际数据库变化产生,而不是手写一条失败消息。

幂等键绑定业务意图

项目用任务 id、器材 id 和数量的规范化摘要生成幂等键。同一个 pending 重试必须保留原键和完整参数,不在每次尝试时用随机数生成新键。工具端账本以键为主键,同时保存参数指纹和结果。若键已存在且指纹相同,直接返回原结果;若同键对应不同参数,拒绝冲突。这样避免调用者误复用键而把一次数量二的请求伪装成此前数量一的成功。键不是秘密,也不是授权票据,执行器授权仍先执行。

参数指纹包含预期库存版本。版本用于乐观并发检查:读取可用三、版本零后,预留只在当前版本仍零时执行。另一个事务已改为版本一,则旧版本请求失败,不能悄悄在新状态下继续。相同业务键的合法重试先检查已存回执,故即使当前版本已经一,也能返回原成功;若把版本检查放在账本检查之前,会错误地把自己的已成功重试判成过期。这个顺序是协议的一部分。

execute(k,p)={rk,k∈L∧H(p)=H(pk)reject,k∈L∧H(p)≠H(pk)commit(p,rk),k∉L\mathrm{execute}(k,p)=\begin{cases}r_k,&k\in L\land H(p)=H(p_k)\\\mathrm{reject},&k\in L\land H(p)\ne H(p_k)\\\mathrm{commit}(p,r_k),&k\notin L\end{cases}
k为业务键、p为参数、L为账本、r_k为已存回执,H为规范参数的SHA256摘要;实际还需权限、版本与库存检查。

为什么效果和回执必须同事务

若先扣库存再保存回执,中间崩溃会留下效果但无键,重试又扣一次。若先保存成功回执再扣库存,中间崩溃会留下“成功”但效果未发生。项目把库存更新和 ledger 插入放在同一 SQLite 事务里:提交时两者同时可见,异常时两者回滚。这个局部原子性来自数据库的事务机制及其文件系统前提,课程将其作为官方文档说明的导入能力,不重新证明磁盘原子提交算法。应用仍要保证两条写入确实在同一事务中。

客户端保存 observed 与工具端事务不是同一次事务:工具先提交,再返回,Runner 随后保存观察。因此中间仍有空窗,但幂等账本使重试能够读取已提交结果并补齐客户端。若远程邮件或支付接口不支持幂等键,这个本地技巧不能自动延伸到远端效果。那时需要查询业务状态、人工核对、补偿或服务端明确的幂等协议。不能仅因为本地测试通过,就给任意外部工具贴上恰好一次标签。

完整算例一:回复丢失但库存只扣一次

相机起始可用三、版本零。第一次 reserve 以键 k、数量一执行,将库存改二、版本一,账本写入结果 r,然后故障让回复丢失。Runner 已记录第四次尝试,但 observations 仍无 reserve,pending 保持原参数。下一次尝试先找到 k,参数指纹一致,返回同一 r,不再更新库存。最终 calls 为五,其中两次尝试同一 reserve;ledger 只有一条,库存仍二。若只看 calls 会误以为做了五个业务动作,若只看成功文字又会漏掉恢复成本。

提交后中断的恢复也相同。关闭并重新打开数据库后,事件重放确认 pending 仍在,下一次重试以同键命中账本,完成观察与结束。测试实际断开连接并以新 Runner 恢复,检查 calls、库存、ledger 和最终状态。这里“最多一次效果”在同一键及局部数据库内成立;不是所有尝试都会成功,预算或永久错误仍可能令业务没有完成。恢复可靠性需要同时说明安全和可完成条件。

独立运行独立标准库事务例:同键相同载荷复用,同键不同载荷拒绝。
import sqlite3
con=sqlite3.connect(':memory:')
con.executescript('CREATE TABLE stock(n INTEGER);INSERT INTO stock VALUES(3);CREATE TABLE receipts(k TEXT PRIMARY KEY,n INTEGER);')
def reserve(key,qty):
    with con:
        old=con.execute('SELECT n FROM receipts WHERE k=?',(key,)).fetchone()
        if old:
            if old[0]!=qty:raise ValueError('payload conflict')
            return
        con.execute('UPDATE stock SET n=n-?',(qty,))
        con.execute('INSERT INTO receipts VALUES(?,?)',(key,qty))
reserve('same',1);reserve('same',1)
assert con.execute('SELECT n FROM stock').fetchone()[0]==2
try:reserve('same',2)
except ValueError:pass
else:raise AssertionError('conflict accepted')
assert con.execute('SELECT n FROM stock').fetchone()[0]==2
con.close();print('one effect, repeated delivery PASS')

完整算例二:重试耗尽仍可能已有副作用

将同一 lost_reply 情形的允许重试数改为零。第一次 reserve 已提交库存二和回执,但客户端收到异常后立即终止 retry_exhausted,observations 仍没有 reserve。若报告把这个终止状态解释为“没预留,所以可以直接发新请求”,新键会再扣一次。项目固定任务 lost-reply-no-retry 预期 retry_exhausted 且效果账本一条;它是刻意保留的未知确认边界。客户端失败与业务效果为零不等价,必须查询同键回执或继续同意图恢复。

再看同键载荷冲突:先以键 k 预留一个相机,再以同键改数量二。工具应拒绝,不返回旧结果冒充本次成功,也不执行新数量。独立测试检查库存仍二。若改为新键但还持旧版本零,也应因版本过期拒绝。两种拒绝来源不同:前者保护身份一致性,后者保护状态前提。错误日志应保留冲突类型,让调用者知道该恢复同意图还是重新读取并建立新业务决定。

哪些错误允许重试

当前 TransientError 只用于明确注入的暂时读失败和回复丢失;ContractError 表示未知对象、参数冲突、过期版本或库存不足,不自动重试。重试必须维持 pending,增加 attempts 和 calls,并受两个上限同时约束。项目没有实际延迟等待或指数退避,因为离线故障一步后消失;真实网络系统常需要退避与抖动避免同一时刻拥塞,但这属于负载控制,还需实际时间与请求流证据,不能在此伪造恢复吞吐。

错误分类本身也要谨慎。超时并不证明服务未执行,所以非幂等写入不能仅按“暂时错误”标签安全重做。反过来,明确的参数验证失败不会随时间自动变合法,反复尝试只耗预算。若存在取消,客户端取消也不等于远程事务已取消;应持久化未确认状态并查询。课程把这些看似同义的“失败”拆开,是为了让恢复策略可以由状态和证据决定,而非由情绪化的再试一次驱动。

第一类检查是重复交付不变量:同键同参数任意重复,库存只变化一次、回执相同。第二类是故障时间点:执行前暂时失败、提交后丢回复、提交后中断、无重试额度分别产生预期状态与效果。只在请求正常成功时测试幂等,没有覆盖真正危险的空窗。当前十七固定任务与十六项单元检查包含这些代表性边界,未实现多工作进程抢占、分布式两阶段事务或无限历史幂等键清理。

账本保留期限也是协议的一部分。当前教学数据库永久保留每个键,没有清理;若生产系统在一天后删除回执,同一旧请求再次到达就可能重新生效。因此“同键最多一次”只在键仍被保留的有效期内有相应保证。扩展时应规定请求有效期、回执保留、迟到请求处理和任务身份唯一范围,不用一个摘要字段替代这些生命周期条件。

练习与项目增量

TRY IT YOURSELF

基础:三种计数

一个业务意图第一次提交后失联,第二次同键成功读回执。业务意图数、尝试数和效果数各是多少?

提示 1
把业务意图、网络尝试、实际库存变化画成三条计数。
提示 2
第二次同key读取的是新效果还是旧回执?再考虑重试若换key会改变哪个身份。
对照参考解答
意图一、尝试二、效果一。第二次返回的是已存结果,不是再次扣减。若每次重试生成新键,就可能变成两个意图身份和两个效果,破坏幂等协议。

TRY IT YOURSELF

进阶:错误事务顺序

先写回执再扣库存,为什么仍不安全?给出中断位置。

提示 1
找出“回执已写而库存未减”的具体中断缝隙。
提示 2
恢复若把该回执当成功会怎样?比较相反写入顺序,判断为何两者仍需同一事务。
对照参考解答
在回执写入后、库存扣减前中断,恢复看到成功回执而跳过执行,造成无实际效果的成功记录。必须将回执与库存变化纳入同一原子事务;反向先扣库存也有重复效果空窗。

TRY IT YOURSELF

挑战:未知结果处理

retry_exhausted能否证明库存未改?用本项目故障给出反例和合适处置。

提示 1
客户端没收到成功确认,不等于服务端没提交。
提示 2
构造提交后丢回复且预算耗尽的路径,查询真实库存/ledger,再决定怎样沿原key确认而非新建意图。
对照参考解答
不能。lost-reply-no-retry在第一次提交后丢失回复,状态重试耗尽但库存已减一。应以原幂等键查询或继续同一pending恢复,并保留未确认状态。不能直接用新键重做,也不能向用户断言业务未发生。

本章增量是与效果同事务的 ledger、参数指纹、过期版本检查和提交后失联故障。下一章检查工具与资料的危险输入边界;即使幂等实现正确,若攻击者能把另一个权限动作塞进执行器,一次错误效果仍然是错误。可靠重试与正确授权必须共同成立。

BUILD THE MECHANISM

补全执行、重试与崩溃恢复

在同一个LearnerRunner.step实现prepared→attempt→工具→observed,使用现有Store原子事务。 下载本课完整项目,在项目根目录运行;只编辑 learner/starter.py,已有构造框架由项目提供。 本阶段检查恢复到 reserve 工具观察即止,状态仍为 running;最终引用验证留给后续 evidence 阶段,不要求提前补完 validate_claims。

输入与输出 prepared先保存,失败工具调用也计费;pending只在观察到结果后清空。
提交后崩溃以同key恢复;最多一次扣库存;重试耗尽可能已提交。

在课程项目目录运行待完成的学习任务
# LearnerRunner.step(替换已提供类的此方法)
def step(self,state,fault=None):
        raise NotImplementedError('execute')
提示 1

先区分服务端commit和客户端observed,画出中间状态。

提示 2

在execute前保存pending与attempt_started,恢复时沿用动作与key。

提示 3

真实临时SQLite制造crash_after_commit;库存应只扣1,ledger只有1行。

独立检查 python3 -m learner.check --stage execute --output runs/learner-execute.json

迁移检验 用新器材与quantity=2重复提交后中断;提交次数和客户端调用次数分开报告。本练习复用已提供Store,不声称学生实现了SQLite事务。

参考实现与解释
在课程项目目录运行参考实现
# LearnerRunner.step(替换已提供类的此方法)
def step(self,state,fault=None):
        if state['status']!='running':return state
        if state['pending'] is None:
            try:
                action=validate_action(self.policy.next_action(copy.deepcopy(state)))
                self.authorize(state,action)
                if action['kind']=='finish':return self.finalize(state,action['status'])
                if state['calls']>=state['task']['max_calls']:
                    state['status']='budget_exhausted';self.store.save(state,'budget_exhausted');return state
            except (ContractError,ValueError,KeyError,TypeError,OSError) as error:
                state['status']='policy_error';state['result']={'error':str(error)};self.store.save(state,'policy_error');return state
            state['pending']=action;state['attempts']=0;state['decisions']+=1
            self.store.save(state,'prepared')
        if state['calls']>=state['task']['max_calls']:
            state['status']='budget_exhausted';self.store.save(state,'budget_exhausted');return state
        state['calls']+=1;state['attempts']+=1;self.store.save(state,'attempt_started')
        try:
            self.authorize(state,state['pending']);result=self.store.execute(state['pending'],fault)
        except TransientError as error:
            if state['attempts']>state['task']['max_retries']:state['status']='retry_exhausted'
            self.store.save(state,'transient:'+str(error));return state
        except ContractError as error:
            state['status']='tool_error';state['result']={'error':str(error)};self.store.save(state,'tool_error');return state
        state['observations'][state['pending']['name']]=result;state['pending']=None
        self.store.save(state,'observed');return state

CHECK YOUR UNDERSTANDING

检验理解

先作答,再决定是否展开解析。选择题用于发现理解缺口;独立实现与迁移任务仍需另行检验。

1. 同一pending重试应使用?

查看解析键绑定业务意图,重试不是新意图。
2. 回执与库存变化应?

查看解析否则崩溃空窗会造成重复效果或假成功。
3. retry_exhausted意味着?

查看解析提交后失联可导致已生效但未确认。
4. 同键不同载荷应?

查看解析身份复用不能悄悄改变业务含义。

自行标记用于记录学习进度,不代表通过项目验收。