Erpilot 系列第 4 篇 · 2026-10-20 发布 · 实现与验收截至 2026-10-19
上一篇:LangGraph 中断与固定版本确认。本篇讨论本机单进程范围的写入可靠性,以及已有故障测试的证据边界。
本文引用的源码与报告保存在随文资料清单中,包含当时尚未推送到项目仓库的工作区内容,文件版本由 SHA-256 校验值标识。代码文本与复现脚本为局部摘录,运行仍需 Erpilot 工作区依赖。
用户批准了一次库存调整,工具随后超时。现在有几种可能:请求尚未进入业务事务,事务执行后回滚了,或者事务已经提交,只是调用方没有收到结果。
“没收到结果”不足以区分这些情况。再次发起相同业务动作,可能补上未完成的操作,也可能重复调整库存。页面对话里没有成功消息,同样不能证明数据库没变。
Erpilot 的处理分两步:业务库为一次写操作保存稳定身份和首次结果;运行时恢复时,先按原身份查询提交事实,再决定如何继续。
一、一次调用要有稳定身份
client_token 标识一次写请求。业务层将工具名、规范化参数与成功结果存入 mutation_requests,以 token 为键。
| 再次请求的条件 | 当前处理 |
|---|---|
| 同 token、同规范化参数 | 返回首次成功结果,不再次变更业务 |
| 同 token、不同规范化参数 | 返回 idempotency_conflict |
| 新 token | 按新的调用身份处理,继续做业务校验 |
规范化需要体现业务等价关系。例如建单会清理客户名前后空格,并合并、排序同 SKU 数量,再形成查重请求。否则仅改变商品输入顺序,也可能被误认为另一种请求。
业务变更和成功结果记录必须同事务提交。若先改库存、再另起事务保存 token,进程可能停在两步之间;业务已发生,但恢复查询没有提交记录。当前 mutation 将查重、变更和结果保存放在同一个写事务里。
token 的稳定范围也要明确。桥接层会为同次工具执行生成并复用 token;图运行时进一步在审批之前将它保存到 checkpoint。恢复时消费原 token,不重新生成。
如果用户重新发出相似指令,模型又提出一个新调用,它可能获得新 token。底层幂等解决的是调用身份重复,不自动判断两句话在业务上是不是同一个意图。
二、用临时库观察重放与冲突
仓库的 examples/articles/04_idempotency.py 演示这条业务契约。它创建临时种子库,读取 A1001 库存,使用同一 token 两次入库,再用该 token 尝试不同数量,最后查询提交记录。
运行入口不需要模型或 PostgreSQL:
uv run python examples/articles/04_idempotency.py本次实际输出为:
{
"observed_delta": 5,
"replay_same_result": true,
"lookup_status": "found",
"different_arguments": "idempotency_conflict"
}这个例子证明该脚本中重复调用只增加了一次库存,异参请求被拒绝。它直接调用领域层,测试的是幂等契约,不经过 Agent 审批,也没有模拟进程崩溃。
完整脚本见复现文件,业务实现见 mutations.py。下面再看这些语义怎样接到恢复流程。
三、并发正确性也要落在事务里
同一个 token 的结果保存正确,并不自动解决不同请求间的库存竞争。两个建单请求如果都先读取同一份库存,再分别写回,仍可能发生超卖或覆盖更新。
当前 SQLite 写路径在读取业务数据之前使用 BEGIN IMMEDIATE 取得写事务,随后做库存校验、修改和结果记账。写事务因此串行进入这段关键路径。锁竞争可能失败,需要把错误交给上层处理;这不是无限等待或容量保证。SQLite 事务文档说明了 IMMEDIATE 事务及可能的忙碌错误。
这个选择适合当前单机演示的约束。更换为 PostgreSQL 业务库时,需要重新设计相应的行锁、唯一约束和编号分配,不能仅替换连接地址就认定并发语义相同。
还要区分 token 幂等与业务对象去重。固定草稿提交在 token 之外检查草稿是否已提交;相同草稿再次提交时返回原单。业务操作预览也保存执行结果。它们为特定对象补了更高一层的重复语义,不能据此推导所有新 token 都会自动去重。
四、恢复时先问业务库
一次已批准写调用的恢复,需要两类事实:checkpoint 记录了原调用与执行位置;业务库记录了该 token 的提交结果。当前实现没有跨 PostgreSQL checkpoint 与 ERP SQLite 的原子事务,因此必须处理两边进度不同的窗口。
MutationReconciler.lookup() 暴露只读查询。它返回 found、absent 或 conflict;查询异常则由运行时作为无法确认处理。
读取原 checkpoint 的 token、参数与 schema 指纹
│
├─ 参数契约不兼容 → unknown,停止写入
│
└─ 查询原 token
├─ found → 回填首次业务结果
├─ absent → 在原批准与契约检查下,用原 token 尝试
└─ conflict / 查询失败 → unknown,等待核对absent 必须来自一次完成的查询。“数据库连不上”不能解释为“没有提交”。否则最需要保守处理的故障,会被转换成新的写入机会。
found 也不只是一个布尔标记。首次结果需要恢复为与原 MCP 工具一致的返回形状,供后续回答和展示使用;不能仅凭当前库存倒推出某次操作的原结果,因为库存可能已被其他操作改变。
当前有对账器的图路径,在确认未命中后执行原调用时禁用执行层自动重试;响应仍不确定则进入 unknown。它不会在同一段逻辑里反复重发业务写入。未装配对账器的路径继续使用工具的重试策略,写工具必须显式具备 retry_safe。
五、在什么位置终止进程,决定验证了什么
普通异常可以验证错误分支,但有时程序仍会执行异常清理。要观察进程突然消失后的持久化结果,需要在独立子进程中硬退出,再由父进程检查数据库和恢复行为。
项目的历史验收按提交窗口拆开:
| 窗口 | 恢复需要核对的事实 |
|---|---|
| 审批 checkpoint 后、尚未展示 | 原 pending、参数与期限是否保持;业务是否未变 |
| 待审批重启并过期 | 期限是否未延长;过期决定是否阻止 handler |
| 批准 checkpoint 后、handler 前 | 是否仍能消费原调用,而非创建另一笔 |
| 事务提交前 | 原尝试是否未提交;原 token 续跑后是否只形成一次业务结果 |
| 事务提交后、graph 保存结果前 | 是否查到已提交结果,避免再次进入 handler |
| 结果已 checkpoint、最终回答前 | 是否仅继续回答,不重复业务操作 |
2026-10-08 的 JUnit 保存了同次硬退出测试 19 项通过、无失败和跳过。其中提交相关窗口参数化覆盖建单、取消、库存调整和商品上下架。父进程核对退出码、调用次数、mutation 记录与业务快照;这些证据属于当时的实现和隔离环境。
特别是提交前窗口,恢复可能让 handler 总共进入两次:原尝试一次,恢复一次。验收要求的是最终业务只提交一次,而非任何情况下 handler 只能进入一次。
这份结果来自历史进程测试 JUnit,逐项解释见验收报告。本次写作没有重新连接 PostgreSQL 或重跑硬崩溃测试,也没有把后续草稿与预览流程自动纳入这份旧矩阵。
六、展示恢复与业务恢复分别处理
业务结果已经提交,最终答复可能尚未生成;最终答复已生成,浏览器也可能没收到。RunStore 保存展示投影,事件用 (run_id, seq) 标识,客户端按序号补发和去重。
这个机制解决阅读连续性,不能决定业务是否执行。展示日志缺少成功事件时,恢复仍应查业务 token;重新展示历史成功事件,也不能再次调用业务工具。
SSE 断开与显式取消同样要分开。断开订阅并不等于撤销已经进入数据库的事务。取消可以阻止尚未开始的副作用;对于结果不明的写调用,仍要查证,不能把取消按钮描述成订单或库存的自动回滚。
当前会话执行权在同一 API 进程内协调,新建、批准、恢复与取消之间有互斥。多 worker 或分布式部署需要另外的数据库级执行权方案,这份验收没有覆盖。
七、保留恢复协议的状态与边界
ADR-0010仍标记“提案,待评审”。已有实现和测试不应被写成该决策已获接受。R01–R06 的证据来自进程级测试,R07–R10 按当时实施计划由组件测试覆盖;当日全仓测试还保留了其他失败,不能把历史批次概括成全仓通过。
这套机制的范围是本机可信单用户、单 API 进程,不包含自动业务补偿、生产身份认证、多租户或跨库事务。幂等记录的生命周期也需要独立管理。
写入结果、图状态与展示记录各自回答一部分问题,恢复时需要按各自的事实来源核对。下一篇从工具名到业务状态的评测说明怎样把这些要求变成可复核的判据。