AI Hero Club

AI 智能体工程 · 第 2 章

已自行标记完成0 / 8

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

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

CHAPTER 02 / 8建议学习 120 分钟

状态、事件与可恢复执行

程序中断后只剩一份对话记录,怎样判断下一步该继续、重试,还是拒绝恢复?

本章要解决的 3 个问题
  • 定义足以恢复的状态而非只保存对话文本
  • 推导事件链与快照的一致性检查
  • 实际中断并恢复同一任务与库存账本
课程项目 ZIP ↓

为什么对话记录不是执行状态

第一章建立了工具输入和授权,本章把执行历史变成可恢复对象。聊天文本可能写“已预留”,却没有事务回执;也可能工具已完成而模型来不及输出。要恢复系统,需要明确任务身份、当前终止原因、已观察结果、尚待执行动作、累计尝试、策略决策计数与结果。这些字段共同构成状态 s。日志里的自然语言解释可以辅助阅读,但不得作为判定库存是否改变的唯一依据。项目用结构化 JSON 状态和 SQLite 事务保存,而不是从聊天文字猜阶段。

任务 id 是幂等恢复的身份,不是显示标题。相同 id 再启动时,必须与原任务各字段完全相同;如果数量或权限变化,应使用新身份,否则恢复会把两个不同业务意图混为一件事。项目 start 先读取原快照、比较任务,再重放事件链核验。没有原状态时才创建新任务。终止状态再次运行只返回已有状态,不重做工具;这与盲目从第一步重新开始不同,后者可能重复预留。

状态转移的最小粒度

运行中的状态至少含 task、status、observations、pending、calls、decisions、attempts、result。observations 按工具名保存已确认结果;pending 保存完整待执行动作,不能只存工具名,否则恢复时参数或幂等键可能重新生成。calls 是实际工具尝试总数,失败也计入;attempts 是当前 pending 的尝试数;decisions 是已准备的工具动作数,用于本地 JSON 策略定位下一动作。三个计数回答不同问题,不能互相替代。

一次正常工具动作按 prepared、attempt_started、observed 三个可持久化边界展开。prepared 先把策略选择和完整参数保存;attempt_started 在调用前增加尝试计数并保存;observed 在结果返回后加入观察并清空 pending。结束动作则验证结果前提后写 finished。这里允许“尝试已记录但效果尚未发生”的中间状态,也允许“效果已提交但观察未保存”的中间状态。后者需要第四章的幂等账本处理,本章不假装仅有状态保存就能跨远程副作用保证恰好一次。

st+1=δ(st,et),terminal(st)⇒δ(st,e)=sts_{t+1}=\delta(s_t,e_t),\qquad \mathrm{terminal}(s_t)\Rightarrow\delta(s_t,e)=s_t
s为状态,e为事件,t为转移序号,delta为转移函数;终止任务再次驱动不新增效果。

事件链与快照的职责

快照让恢复快速取得最近状态,事件链让读者核查状态如何到达。项目每次 save 在同一 SQLite 事务里插入一个事件并更新快照,事件包含类型和该次完整状态。按任务维护从零开始连续编号,记录前一事件摘要,并对编号、前摘要及载荷计算 SHA256。保存完整状态比只存增量更占空间,但便于教学重放;真实系统可采用更紧凑事件和周期快照,前提是仍能验证重建结果与当前快照一致。

Hi=SHA256⁡(canon⁡(i,Hi−1,Ei)),H−1=064H_i=\operatorname{SHA256}(\operatorname{canon}(i,H_{i-1},E_i)),\qquad H_{-1}=0^{64}
E为事件载荷,i为事件序号;canon是固定键顺序、UTF-8和固定JSON分隔的序列化。摘要检测内容变化,不提供身份签名。

规范化序列化避免同一个字典仅因键顺序不同而产生不同摘要。项目按排序键、紧凑分隔与保留中文编码生成字节。重放逐项核对编号连续、previous 相同、摘要重算相同,最后比较最后事件状态与 states 表快照。这个检查可以发现删改中间事件或只修改快照。它不是防管理员篡改的密码学审计系统:能够同时重写全部事件和摘要的人仍能伪造新链,若需抗此威胁必须增加外部锚定或签名。

完整算例一:两个工具后关闭进程

以 reserve-camera 为例,初始状态 calls 为零、观察空。执行 search 后 calls 为一并持有唯一命中;执行 document 后 calls 为二并持有正文与内容摘要。此时 stop_after=2 返回 running,数据库已经保存两步观察。关闭连接后重新打开同一个数据库、载入同一任务,策略读到已有 search 与 document,只提出 stock,再提出 reserve,最后结束。恢复后的完整状态与不间断运行相同,包括调用次数四和最终库存二;它不是只比较一句“成功”文字。

在课程项目目录运行项目目录运行:真实关闭并重开数据库,从中间观察继续。
import pathlib,tempfile,json
from agent import Store,Runner
from run import ROOT
row=json.loads((ROOT/'data/tasks.jsonl').read_text().splitlines()[1])
with tempfile.TemporaryDirectory() as td:
    path=pathlib.Path(td)/'state.sqlite'
    store=Store(path,ROOT/'data');store.import_documents()
    partial=Runner(store).run(row['task'],stop_after=2)
    assert partial['calls']==2 and partial['status']=='running'
    store.db.close()
    store=Store(path,ROOT/'data')
    final=Runner(store).run(row['task'])
    assert final['status']=='reserved' and final['calls']==4
    assert store.replay(row['task']['id'])==final
    store.db.close()
print('resume PASS')

完整算例二:只保存快照会漏掉什么

假设正常事件零为 created,后续有 prepared 和 observed。有人只修改 states 表,将结果变成 reserved,却没有任何 reserve 观察或回执。如果恢复只相信快照,会把虚构结果当完成。项目 replay 会把最后一条事件状态与快照比较,发现不相等而拒绝。反过来若修改事件 payload 而不更新摘要,也会在该事件处失败。测试实际执行 SQL 改写载荷,要求抛出 broken event chain,而不是凭肉眼检查日志。

还有一种更普通的错误:重复编号或跳号。项目的主键约束防止同任务同序号插入两次,重放枚举再要求 seq 等于从零开始的实际次序。不同任务各有自己的编号链,不应要求全数据库事件严格交错编号。由于当前只支持单工作进程驱动一任务,没有实现多进程抢占与租约;两个工作者同时驱动的竞态不在验收范围。数据库事务提供局部原子性,但不能自动为任意应用协议补上并发所有权。

保存成功不等于副作用已确认

若进程在 attempt_started 之后、真正调用之前中断,恢复看到 pending,重试是合理的;若在工具事务提交之后、observed 之前中断,同一个 pending 也会被重试。仅靠客户端状态无法从这两种外观相同的状态判断工具是否已生效。项目因此让工具端持久化幂等回执,恢复以同一键重试时读取已有结果。这是跨边界不确定性的核心,不应把“每步都存盘”误写为自动避免所有重复效果。第四章会逐个时间点推导。

范围:当前资料版本冻结策略

状态还应绑定资料版本。项目首次导入保存文档正文和 SHA256,后续 import_documents 发现同身份文档的内容变了就拒绝,要求新数据库。这是一个保守冻结策略:恢复不会悄悄换成新版本资料,原引用仍有相同内容依据。实际在线系统若支持版本更新,需要显式版本选择、撤销和迁移,本课不声称当前数据库支持这类动态知识维护;上下文工程课程会单独研究版本与撤销。

第一类检查是路径等价:在零到四步多个切点分别停下再恢复,对照完整状态、库存与回执。第二类是损坏检出:改任务内容、改事件载荷、改快照或来源版本时不能静默继续。前者证明常见合法中断能恢复,后者检验系统不会把不一致对象当作正常历史。单个“存盘后文件存在”的断言覆盖不了这些语义。当前测试在真实 SQLite 文件上执行,不用伪造内存字典模拟持久化。

范围:应用中断与硬件故障

持久化还有明确的失败边界:SQLite提交成功是在官方事务和文件系统假设下成立,本课不直接测试真实断电、电盘损坏或恶意管理员。我们实际注入的是应用进程边界的中断,并重开文件验证。数据库文件存在不表示最后一次save已经提交;恢复时应只读已提交快照并核对事件,而不是根据控制台打印过什么推断落盘。课程不把应用级故障恢复夸大为全部硬件故障保证。

拓展:事件快照的存储代价

完整状态保存也有代价。每事件携带一份快照,若文档正文很长,日志会重复保存大量内容。这里选择小型冻结资料来保持可读性,并让重放机制容易核查。扩展时可以把不可变正文按摘要存一次,事件仅引用身份,再定期压缩状态;但压缩后必须保留可重建来源和验证规则。本课没有将未实现的事件压缩当作已有能力,也不以当前几份文档的体积推断生产存储规模。

独立运行标准库独立检查,不依赖项目目录。
import hashlib,json
def hashed(obj):
    raw=json.dumps(obj,sort_keys=True,separators=(',',':')).encode()
    return hashlib.sha256(raw).hexdigest()
previous='0'*64
first={'seq':0,'previous':previous,'payload':{'status':'running'}}
h0=hashed(first)
second={'seq':1,'previous':h0,'payload':{'status':'info'}}
assert second['previous']==hashed(first)
changed={**first,'payload':{'status':'reserved'}}
assert second['previous']!=hashed(changed)
print('chain and mutation PASS')

练习与项目增量

TRY IT YOURSELF

基础:pending需要哪些内容

为何只保存pending="reserve"不足以恢复?至少列出四个还必须保存的参数。

提示 1
恢复要重放的是完整动作,不能只知道动作名称。
提示 2
检查目标、数量、版本和幂等身份这四类信息,尤其不能在重试时重新生成业务身份。
对照参考解答
还需item_id、quantity、expected_revision和idempotency_key。恢复时重新生成这些参数可能改变目标、数量、版本或身份;尤其新幂等键会把同一业务意图变成新效果。项目保存完整动作对象,并把原任务作为固定前提。

TRY IT YOURSELF

进阶:哈希链的保证边界

攻击者能改写数据库全部表并重算每条摘要。链通过后能证明历史真实吗?

提示 1
摘要链能检测局部不一致,但链末是否有独立可信锚?
提示 2
假设攻击者一起改payload与全部后继摘要,判断仅验证链内部一致性是否仍能区分伪造历史。
对照参考解答
不能。哈希链检测未同步更新的损坏和局部篡改,不等于外部签名或不可篡改日志。能够重写全链的人可构造一致的新历史。需要外部可信锚、权限隔离或签名才能处理更强威胁;本课只声明本地完整性检查。

TRY IT YOURSELF

挑战:两种相同pending

在调用前崩溃和工具提交后崩溃,客户端都保存了同一pending。为什么仅检查客户端不能决定是否再次扣库存?

提示 1
画出提交前中断与提交后未观察的两条时序,它们在客户端看起来可能相同。
提示 2
问谁能可靠知道效果是否落库;同键回执应与效果共同保存,而不能靠客户端猜是否重做。
对照参考解答
客户端没有得到区别两者的确认信息。若直接重做可能重复扣,若直接跳过可能漏扣。应对同一业务意图保持同一幂等键,由工具在与效果同一事务中保存回执,重试先查该键再决定执行;客户端随后记录观察。这依赖工具端协议,不能由对话状态单方保证。

本章交付是 Store.save、snapshot、replay 和可断点运行的 Runner。下一章把策略视为可替换的不受信动作提议器,明确什么时候继续、什么时候停止;状态与日志已经可追溯,所以替换决策源后仍能使用相同的工具与恢复检查。

BUILD THE MECHANISM

构造任务绑定的持久状态

在真实LearnerRunner.start中建立、保存并恢复任务状态。 下载本课完整项目,在项目根目录运行;只编辑 learner/starter.py,已有构造框架由项目提供。

输入与输出 同task id必须绑定同一完整请求;新状态calls=0、pending=None。
保存后快照与事件重放相同;重启不默默创建第二份任务。

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

先区分新任务与已有快照,不能先覆盖旧状态。

提示 2

已有同id任务先比较完整task再检查replay。

提示 3

改变quantity但保留id,应该拒绝,而不是扣成新数量。

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

迁移检验 新增一个只读任务,保存后重开同SQLite;检查状态身份与计数,不执行任何真实外部借用。

参考实现与解释
在课程项目目录运行参考实现
# LearnerRunner.start(替换已提供类的此方法)
def start(self,task):
        validate_task(task);old=self.store.snapshot(task['id'])
        if old:
            if old['task']!=task:raise ContractError('task ID reused with different request')
            self.store.replay(task['id']);return old
        state={'task':task,'status':'running','observations':{},'pending':None,'calls':0,'decisions':0,'attempts':0,'result':None}
        self.store.save(state,'created');return state

CHECK YOUR UNDERSTANDING

检验理解

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

1. 事件和快照应怎样更新?

查看解析否则崩溃可能留下两者不一致。
2. 相同任务id但数量改变应?

查看解析任务身份必须绑定完整业务意图。
3. 哈希链主要检查?

查看解析它不自动提供事实真实性或外部不可篡改保证。
4. 只保存客户端状态能消除提交后失联吗?

查看解析客户端无法区分调用前和效果后未确认,需要服务端效果回执。

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