Skip to content

刷新页面,回答还在生成:AI 对话的持久化与断线恢复 ​

回答生成到一半按下刷新。页面重新打开后,是显示半条消息、从头生成、接着输出,还是告诉用户任务结果未知?这四种行为背后是四套不同能力。

🎯 本文目标

分清“加载历史消息”“重放尚未读完的流”“继续仍在运行的任务”“从检查点重启任务”;给出浏览器、数据库、流存储和 worker 的职责,以及游标、TTL、多标签页、取消和安全边界。

本篇边界

本文不再解释 SSE 帧,也不重复 thread / message 模型。请先看消息协议篇和对话状态篇。这里专注连接丢失后,事实在哪里、怎样重新同步。


“恢复”至少有四层,不能用一个 resume 概括 ​

能力页面刷新后能看到什么还需要什么
消息持久化已完成并入库的历史消息数据库 + 查询接口
流重放接回仍在产生、且被缓存的字节 / 事件stream store + cursor + resume endpoint
任务续跑浏览器断开后,模型 / Agent 仍在服务端运行与请求生命周期解耦的 producer / worker
检查点恢复worker 或进程崩溃后从某一步继续durable checkpoint + 可重入节点 / job

Redis 里保留流,不代表生成任务还活着;数据库里有半条 message,也不代表能找回漏掉的 token。先说清需要哪一级,再选库。


四个事故,失败位置完全不同 ​

text
A. 浏览器刷新:连接断,服务端任务可能仍在
B. 手机掉网:连接长时间断开,回来时流可能过期
C. 用户关闭页面:没有客户端,但任务是否继续是产品选择
D. 服务进程重启:连接、内存流和进程内任务都可能消失

A/B 可以靠流重放处理;D 若要求继续,必须有外部 worker 和 checkpoint。不要用“支持断线重连”暗示“支持服务重启续跑”。


三份持久状态 ​

一个可恢复 run 至少涉及:

typescript
type RunRecord = {
  runId: string
  threadId: string
  assistantMessageId: string
  status: 'queued' | 'streaming' | 'completed' | 'failed' | 'cancelled'
  streamId: string | null
  lastSequence: number
  startedAt: string
  finishedAt: string | null
}
存储保存什么生命周期
业务数据库thread、规范化 messages、run 最终状态长期、可查询
流存储编码后的帧或协议事件、游标、TTL短期、用于补读
checkpoint / job storeAgent 当前节点、输入、重试信息任务执行期,按业务保留

浏览器 localStorage 可以保存草稿和最近 thread ID,但不应成为多设备消息真相。


推荐链路:先建 run,再订阅它 ​

text
Browser ── POST /threads/:id/runs ──→ API
                                      ├─ DB: create run + assistant message
                                      └─ enqueue producer

Browser ── GET /runs/:runId/events?after=41 ─→ stream store
Worker  ── append seq 42,43... ──────────────→ stream store
Worker  ── finalize message + run ───────────→ DB

关键点是 run 的身份先于连接存在。连接只是订阅方式,断开不会让 run 失去 ID。

创建接口最好返回:

json
{
  "runId": "run_7",
  "messageId": "msg_9",
  "status": "queued",
  "resumeUrl": "/api/runs/run_7/events"
}

恢复时客户端带上已确认游标:

http
GET /api/runs/run_7/events?after=41
Authorization: Bearer ...

服务端先重放 sequence > 41 的事件,再转入实时订阅。每个事件仍要靠 eventId 去重,不能相信网络只投递一次。


客户端恢复算法 ​

typescript
async function resumeRun(runId: string) {
  setConnection('resuming')

  const cursor = getLastAppliedSequence(runId)
  const response = await fetch(`/api/runs/${runId}/events?after=${cursor}`)

  if (response.status === 410) {
    // 短期流已过期,退回数据库快照。
    await reloadThreadSnapshot()
    return
  }

  if (!response.ok || !response.body) {
    setConnection('disconnected')
    return
  }

  setConnection('streaming')
  for await (const event of decodeEventStream(response.body)) {
    applyEventExactlyOnce(event)
    persistCursorAfterApply(event.runId, event.sequence)
  }
}

游标必须在 reducer 成功应用后再推进。先保存游标、后更新消息,会在页面崩溃时永久跳过一段内容。

连接状态不要冒充 run 状态 ​

text
connection: connecting | streaming | disconnected | resuming
run:        queued | running | completed | failed | cancelled

disconnected + running 是合法组合。UI 应显示“连接中断,任务可能仍在运行”,而不是立刻把 run 标红为失败。


快照与事件日志怎样配合 ​

无限重放所有 token 成本很高。常见方案是:

text
消息快照(截至 seq 800) + 增量事件(seq 801...)

恢复流程:

  1. 读 thread / message snapshot;
  2. 取得该 snapshot 的 lastSequence;
  3. 订阅 after=lastSequence 的事件;
  4. 运行完成后,把最终消息原子化落库;
  5. stream TTL 到期后清理短期事件。

不要同时把“半条正文”从数据库和流中各拼一次。快照必须声明它覆盖到哪个 sequence。


AI SDK 的 resume 模式解决到哪里 ​

AI SDK 官方方案使用 useChat({ resume: true }),页面加载时向 stream endpoint 发起 GET;服务端需要持久化 chat 与 active stream 的映射,并使用外部存储保存可恢复流。它解决的是重新连接到仍可恢复的 UI message stream,不是自动提供数据库 schema 或崩溃恢复 worker。

官方文档还明确指出一个容易忽略的取舍:其示例架构中 resumable streams 与 abort 不兼容,因为页面关闭触发的 abort 会终止流。产品必须先决定:

  • “停止”是否真的取消服务端生成;
  • 刷新 / 断开是否只取消订阅;
  • 用户主动取消与网络中断怎样区分。

不要让同一个 AbortController 同时承担“离开页面”和“取消任务”两种语义。


assistant-ui 的 byte-level resume 又有什么不同 ​

assistant-ui 的 resumable stream 指南把已编码的 LLM 响应持久化在服务端,客户端可在刷新、断开,甚至另一标签页 / 设备中恢复读取。字节级持久化的优势是能适配多种上层流格式,不必先理解每个业务事件。

代价也很清楚:

  • 编码格式一旦发布,也成为兼容契约;
  • 字节能重放,不代表业务命令可安全重放;
  • producer 崩溃后是否续跑,仍取决于运行基础设施;
  • 最终消息如何规范化入库,仍要由应用决定。

选择 byte stream 还是 semantic event stream,本质是在选择恢复层级。前者更贴近传输,后者更方便按事件检查、去重和迁移。


服务重启:真正的分水岭 ​

如果生成逻辑直接运行在处理 POST 的 Web 进程里:

text
Web process crash
  → producer 消失
  → 后续没有新事件
  → resume 只能读回崩溃前缓存

要跨进程重启续跑,需要:

  1. 把 run 写入 durable queue;
  2. worker 领取 run,并周期性写 checkpoint;
  3. 每个节点可重入,外部副作用有幂等键;
  4. lease 超时后允许另一个 worker 接管;
  5. 接管者从 checkpoint 恢复,而不是盲目从头执行。

长 Agent 工作流值得这样做;几秒钟的普通问答也许只需要诚实标记失败并允许重新生成。可靠性等级应由业务价值决定。


多标签页与取消语义 ​

两个标签页同时订阅同一 run 没问题,两个标签页同时发起同一命令则可能重复执行。建议分离:

操作ID / 约束
创建回答client request ID,服务端去重
订阅回答run ID + cursor,可多订阅者
主动取消POST /runs/:id/cancel,鉴权且幂等
页面卸载只断开当前订阅,不隐式取消 run

如果产品希望“最后一个订阅者离开就取消”,也应由服务端基于明确策略判断,而不是依赖不可靠的 beforeunload 请求。


TTL、权限与隐私 ​

  • resume URL 不能靠不可猜 ID 代替鉴权;每次恢复都校验用户对 thread / run 的访问权;
  • 流中可能包含工具参数和个人信息,存储需加密并控制日志采样;
  • 分别定义 stream TTL、消息保留期、checkpoint 保留期;
  • 过期返回明确的 410 Gone,客户端退回最新数据库快照;
  • run 完成、取消、长期失败后清理 active-stream 映射;
  • 租户 ID 必须进入查询条件,而不只来自客户端 payload。

恢复测试矩阵 ​

注入故障期待结果
收到第 20 个事件后刷新从 21 继续,不重复正文
第 20 个事件应用后、游标落盘前崩溃重放 20,但 eventId 去重
切线程时旧流继续返回旧事件不污染新 thread
离线 30 秒后恢复TTL 内补读;状态从 resuming 回到 streaming
stream 已过期410 后加载快照,不无限重试
多标签页同时订阅两端一致,不创建第二个 run
用户主动取消run 最终 cancelled,所有订阅者收到终态
Web 进程重启按承诺:明确失败,或 worker 从 checkpoint 接管

这比手动刷新一次看到“好像还在”更接近可验证的恢复能力。


结语:恢复的是哪一层,必须写在产品承诺里 ​

最小聊天产品只做消息持久化完全合理;重要长任务可能需要事件重放、后台 producer,甚至 checkpoint。问题不在于能力越多越好,而在于不要混称:

历史可见不等于流可重放,流可重放不等于任务仍在运行,任务仍在运行不等于服务崩溃后能续跑。

把这四句话写进架构说明,前端的 loading、错误和恢复按钮才会与真实系统能力一致。


📚 核对范围与官方资料

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人