为什么对话记录不是执行状态
第一章建立了工具输入和授权,本章把执行历史变成可恢复对象。聊天文本可能写“已预留”,却没有事务回执;也可能工具已完成而模型来不及输出。要恢复系统,需要明确任务身份、当前终止原因、已观察结果、尚待执行动作、累计尝试、策略决策计数与结果。这些字段共同构成状态 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。这里允许“尝试已记录但效果尚未发生”的中间状态,也允许“效果已提交但观察未保存”的中间状态。后者需要第四章的幂等账本处理,本章不假装仅有状态保存就能跨远程副作用保证恰好一次。
事件链与快照的职责
快照让恢复快速取得最近状态,事件链让读者核查状态如何到达。项目每次 save 在同一 SQLite 事务里插入一个事件并更新快照,事件包含类型和该次完整状态。按任务维护从零开始连续编号,记录前一事件摘要,并对编号、前摘要及载荷计算 SHA256。保存完整状态比只存增量更占空间,但便于教学重放;真实系统可采用更紧凑事件和周期快照,前提是仍能验证重建结果与当前快照一致。
规范化序列化避免同一个字典仅因键顺序不同而产生不同摘要。项目按排序键、紧凑分隔与保留中文编码生成字节。重放逐项核对编号连续、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
对照参考解答
TRY IT YOURSELF
进阶:哈希链的保证边界
攻击者能改写数据库全部表并重算每条摘要。链通过后能证明历史真实吗?
提示 1
提示 2
对照参考解答
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