a↗Agent 工程实战中文课程 / v1.0.0⌕ 搜索课程/
本页目录

M06 服务化、事件流与数据一致性

本章产出:稳定的任务API、可重连事件流、数据库对象关系和Outbox设计。网页展示与任务执行生命周期分离。

1. 聊天连接不是任务所有者

用户关闭标签页,不一定想取消已经批准的任务。反过来,连接还活着也不能证明任务仍应运行。服务必须把“连接”“会话”“任务”“一次执行尝试”分成不同对象。

课程约定:conversation_id关联对话;run_id是一项可验收任务;attempt_id是某次Worker执行;operation_id是一项业务副作用意图。一个run可以经历多次attempt,但同一退款意图的operation_id不能随恢复变化。

2. 一个足够完整、但不臃肿的API

POST /v1/runs接收任务,客户端提供请求幂等键,认证主体来自会话。服务端创建run并返回202与run_id。相同主体、相同键、相同有效请求返回原run;相同键却不同请求返回冲突。

GET /v1/runs/{id}读取授权范围内的状态。GET /v1/runs/{id}/events?after_seq=n获取事件。POST /v1/approvals/{id}/decision处理批准或拒绝;POST /v1/runs/{id}/cancel提出取消请求。详细字段与错误码在specs/API_CONTRACT.md。

API的幂等键与退款操作的幂等键属于不同层次。用户刷新提交按钮需要去重创建run;同一个run恢复时需要去重退款;不能用一个未经定义的UUID解决所有去重问题。

权限检查必须覆盖读取、事件订阅、取消、审批和artifact下载。只给创建接口加鉴权,会让run_id成为越权读取入口。可猜性低的ID是辅助,不是访问控制。

3. 三个视图,不要共用一张随意JSON表

业务表保存订单、审批和操作事实;执行表保存run状态、版本、租约和预算;事件表保存面向UI或恢复的可排序事件。小项目可以在同一个关系数据库里实现,不必立刻拆微服务。

一个run状态行包含status、version、owner、lease_until、fence、cancel_requested。事件行包含run_id、seq、type、payload和created_at,以(run_id,seq)唯一约束避免重复序号。

状态更新与对应事件插入应该在同一事务中。否则可能出现任务已经成功但UI永远没有成功事件,或者UI显示成功但事务回滚。数据库事务的边界是明确的本地边界,不延伸到模型API。S30

4. SSE是通知机制,不是执行引擎

浏览器可以通过Server-Sent Events查看进度。服务端为每个事件提供稳定ID,客户端重连时带上最后已处理序号。重放可能包含重复事件,客户端按ID去重;事件缺口则重新获取状态或历史。

关键不变量是:重连不重新创建run、不重新调用工具、不把已经收到的成功事件执行第二次。UI应该以服务端持久状态为事实源,而不是只靠一条内存中的流。

模型token流与业务事件流不同。模型的增量文本只表示生成进度;refund.committed这样的业务事件必须来自确定的业务结果。供应商也可能采用SSE输出模型响应,但你自己的任务事件协议应独立定义。S36

I12 · 互动推演独立打开 ↗

SSE重连与重复事件

网络重传可以重复,界面投影必须去重并保持顺序。

合成教学数据 · 未运行真实模型
静态推演与完整规格

I12 SSE重连与重复事件

关联章节:M06 · 交互类型:event-replay · 状态:提供完整状态/验收规格,UI待实现

教学目标

网络重传可以重复,界面投影必须去重并保持顺序。

初始状态

{
  "server_events": [
    1,
    2,
    3,
    4,
    5
  ],
  "client_seen": [
    1,
    2
  ],
  "after_seq": 2
}
操作控件
  • 断开连接

  • 收到重传帧2/3/3/4

  • 更改游标

  • 请求快照

  • 重建UI投影

状态变化规则
  • 按(run_id,seq)去重,不按文本内容去重。

  • 游标只在事件应用成功后推进。

  • 游标过旧或缺失事件范围时显示resync_required,加载快照再续接。

黄金案例与反馈
duplicates

输入:


{
  "seen": [
    1,
    2
  ],
  "frames": [
    2,
    3,
    3,
    4
  ]
}

期望:


{
  "projection": [
    1,
    2,
    3,
    4
  ],
  "after_seq": 4
}

同一事件只能应用一次。

stale-cursor

输入:


{
  "after_seq": 1,
  "minimum_retained_seq": 10
}

期望:


{
  "status": "resync_required"
}

不能跳过缺口假装完整。

完成条件
  • 重复帧不重复渲染

  • 说明快照与流的版本边界

反馈层次
  • 提交预测后显示结果;允许跳过预测但记录跳过,不阻止阅读

  • 结果同时显示状态变化、原因与被拒绝动作;不是只给红绿灯

  • 第一次错误给边界提示,第二次提供最小反例;参考推导始终可主动打开

  • 重置恢复固定初始状态并保留历史尝试供用户手动清除

无障碍与静态替代
  • 全部操作有原生button/input/select替代,不依赖拖拽

  • 图与动画提供同步文字状态表;支持prefers-reduced-motion

  • 状态改变用aria-live=polite摘要,不逐字朗读长日志

  • 焦点不跳走;错误提示关联输入;颜色不是唯一编码

无JavaScript时直接展示初始输入、规则和黄金案例表,读者仍能完成纸面推演。状态动画仅作解释,不能成为获取答案的唯一途径。

持久化与重置

使用版本化键agent-course:1:I12,仅保存本地学习状态。支持单卡重置和全站清除;存储失败时以内存模式继续,不阻断阅读。

必须避免的误导

演示事件不是真实网络测量,刷新连接也不等于取消任务。

执行边界

纯本地教学状态机;不执行任意代码,不连接真实资金/模型/宿主Shell。

5. 慢客户端不应该拖住退款任务

服务端不能为了一个断网浏览器无限缓存token。为连接设置有限缓冲;超限时停止推送并要求客户端按游标恢复。持久化的任务状态仍继续推进。

不要把大日志、完整文件和每个token都塞进高频事件表。事件保存摘要和artifact引用;原始输出放到有配额、有权限检查的对象存储或本地教学目录。审计所需内容则按明确保留策略保存。

跨实例推送可以用消息通道唤醒订阅者,但持久事件表是恢复来源。消息通知丢失后,客户端或服务端轮询仍能发现新事件。临时Pub/Sub不应承担唯一事实源的角色。

6. 创建任务成功,入队失败怎么办

一种常见代码是:先插入run并提交,再发送队列消息。进程恰好在两步之间崩溃,数据库里有任务,队列里却没有。反过来先发送消息再提交,Worker可能处理一个最终没有创建成功的任务。

Outbox的做法是把run状态和“需要投递的消息”写在同一数据库事务。独立投递器读取Outbox,把消息发到队列,然后标记已发送。这样解决的是本地写入与投递意图的一致性。

投递器可能在发送成功后、标记前崩溃,所以消息仍可能重复。消费者必须幂等,必要时用Inbox或run状态版本去重。Outbox不是端到端exactly-once开关。

I13 · 互动推演独立打开 ↗

Outbox在哪个崩溃窗口救了你?

明确提交与网络投递不能靠同一普通数据库事务自动绑定。

合成教学数据 · 未运行真实模型
静态推演与完整规格

I13 Outbox在哪个崩溃窗口救了你?

关联章节:M06 · 交互类型:crash-timeline · 状态:提供完整状态/验收规格,UI待实现

教学目标

明确提交与网络投递不能靠同一普通数据库事务自动绑定。

初始状态

{
  "run": false,
  "outbox": false,
  "broker_messages": 0,
  "sent": false
}
操作控件
  • 提交Run+Outbox

  • 发送消息

  • 标记sent

  • 任一步注入崩溃

  • 恢复发布器

  • 切换无Outbox方案

状态变化规则
  • 事务提交同时产生run和outbox;事务前崩溃两者都不存在。

  • 发送成功未mark_sent后恢复允许再次发送同一message_id。

  • 消费者按稳定标识去重,最终只启动一个逻辑Run。

黄金案例与反馈
after-db

输入:


[
  "commit",
  "crash",
  "recover"
]

期望:


{
  "run": true,
  "outbox": true,
  "eventual_dispatch": true
}

恢复能发现待发消息。

after-publish

输入:


[
  "commit",
  "publish",
  "crash",
  "recover"
]

期望:


{
  "broker_messages": 2,
  "logical_runs": 1
}

重复交付不是Outbox失效;消费者需要幂等。

no-outbox

输入:


[
  "save_run",
  "crash_before_publish"
]

期望:


{
  "dispatch_guaranteed": false
}

仅写run后“等会发消息”会丢工作。

完成条件
  • 找出丢消息窗口

  • 解释为什么仍可能重复

反馈层次
  • 提交预测后显示结果;允许跳过预测但记录跳过,不阻止阅读

  • 结果同时显示状态变化、原因与被拒绝动作;不是只给红绿灯

  • 第一次错误给边界提示,第二次提供最小反例;参考推导始终可主动打开

  • 重置恢复固定初始状态并保留历史尝试供用户手动清除

无障碍与静态替代
  • 全部操作有原生button/input/select替代,不依赖拖拽

  • 图与动画提供同步文字状态表;支持prefers-reduced-motion

  • 状态改变用aria-live=polite摘要,不逐字朗读长日志

  • 焦点不跳走;错误提示关联输入;颜色不是唯一编码

无JavaScript时直接展示初始输入、规则和黄金案例表,读者仍能完成纸面推演。状态动画仅作解释,不能成为获取答案的唯一途径。

持久化与重置

使用版本化键agent-course:1:I13,仅保存本地学习状态。支持单卡重置和全站清除;存储失败时以内存模式继续,不阻断阅读。

必须避免的误导

不把Outbox说成exactly-once消息系统。

执行边界

纯本地教学状态机;不执行任意代码,不连接真实资金/模型/宿主Shell。

7. 数据库队列与Redis什么时候用

课程基线可以直接用PostgreSQL任务表配合短事务领任务;FOR UPDATE SKIP LOCKED常用于避免多个消费者等待同一已锁行,但它不提供公平调度,也不自动形成长期租约。S31

领任务时选一行,写入owner、lease和递增fence,然后尽快提交。不要在持有行锁的事务里等待模型几十秒。工作期间通过短事务续租,完成时用版本和fence条件提交。

Redis可以承担速率限制、短期缓存或队列,但每引入一个存储,就增加一类一致性问题。先明确哪一个存储拥有事实、消息是否可重复、持久化配置与恢复流程,再决定是否需要它。不是Backend项目就必须出现Redis。

8. 并发下的状态更新

设run版本为7。Worker A和B都读到7。A把它改为等待审批并写入版本8;B迟到地把它改为失败。若B直接覆盖,用户刚看到的审批消失;若B使用WHERE version=7,更新失败后重新读取即可发现状态已变。

这类乐观并发控制保护数据库状态,但不能自动阻止B已经发出的外部退款。外部副作用需要M07的幂等与fencing边界。不要把本地CAS的保证扩大到整个系统。

9. 服务化测试与交付

L05第二部分要求验证:重复创建run只产生一项任务;跨用户读取事件被拒绝;断线后事件可恢复;重复审批不重复提交操作;API重启不清空任务;Outbox重复投递不造成重复业务效果。

网页Agent不需要在课程网站里直接部署这个业务后端。网站展示协议、状态机和模拟器;学习者在本地完成服务实验。这样既减少实现负担,也避免让学习网站意外成为一个允许任意代码执行的公共服务。

做当章练习 →