刷新页面,回答还在生成:AI 对话的持久化与断线恢复
回答生成到一半按下刷新。页面重新打开后,是显示半条消息、从头生成、接着输出,还是告诉用户任务结果未知?这四种行为背后是四套不同能力。
🎯 本文目标
分清“加载历史消息”“重放尚未读完的流”“继续仍在运行的任务”“从检查点重启任务”;给出浏览器、数据库、流存储和 worker 的职责,以及游标、TTL、多标签页、取消和安全边界。
“恢复”至少有四层,不能用一个 resume 概括
| 能力 | 页面刷新后能看到什么 | 还需要什么 |
|---|---|---|
| 消息持久化 | 已完成并入库的历史消息 | 数据库 + 查询接口 |
| 流重放 | 接回仍在产生、且被缓存的字节 / 事件 | stream store + cursor + resume endpoint |
| 任务续跑 | 浏览器断开后,模型 / Agent 仍在服务端运行 | 与请求生命周期解耦的 producer / worker |
| 检查点恢复 | worker 或进程崩溃后从某一步继续 | durable checkpoint + 可重入节点 / job |
Redis 里保留流,不代表生成任务还活着;数据库里有半条 message,也不代表能找回漏掉的 token。先说清需要哪一级,再选库。
四个事故,失败位置完全不同
A. 浏览器刷新:连接断,服务端任务可能仍在
B. 手机掉网:连接长时间断开,回来时流可能过期
C. 用户关闭页面:没有客户端,但任务是否继续是产品选择
D. 服务进程重启:连接、内存流和进程内任务都可能消失A/B 可以靠流重放处理;D 若要求继续,必须有外部 worker 和 checkpoint。不要用“支持断线重连”暗示“支持服务重启续跑”。
三份持久状态
一个可恢复 run 至少涉及:
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 store | Agent 当前节点、输入、重试信息 | 任务执行期,按业务保留 |
浏览器 localStorage 可以保存草稿和最近 thread ID,但不应成为多设备消息真相。
推荐链路:先建 run,再订阅它
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。
创建接口最好返回:
{
"runId": "run_7",
"messageId": "msg_9",
"status": "queued",
"resumeUrl": "/api/runs/run_7/events"
}恢复时客户端带上已确认游标:
GET /api/runs/run_7/events?after=41
Authorization: Bearer ...服务端先重放 sequence > 41 的事件,再转入实时订阅。每个事件仍要靠 eventId 去重,不能相信网络只投递一次。
客户端恢复算法
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 状态
connection: connecting | streaming | disconnected | resuming
run: queued | running | completed | failed | cancelleddisconnected + running 是合法组合。UI 应显示“连接中断,任务可能仍在运行”,而不是立刻把 run 标红为失败。
快照与事件日志怎样配合
无限重放所有 token 成本很高。常见方案是:
消息快照(截至 seq 800) + 增量事件(seq 801...)恢复流程:
- 读 thread / message snapshot;
- 取得该 snapshot 的
lastSequence; - 订阅
after=lastSequence的事件; - 运行完成后,把最终消息原子化落库;
- 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 进程里:
Web process crash
→ producer 消失
→ 后续没有新事件
→ resume 只能读回崩溃前缓存要跨进程重启续跑,需要:
- 把 run 写入 durable queue;
- worker 领取 run,并周期性写 checkpoint;
- 每个节点可重入,外部副作用有幂等键;
- lease 超时后允许另一个 worker 接管;
- 接管者从 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、错误和恢复按钮才会与真实系统能力一致。
📚 核对范围与官方资料
- AI SDK:Chatbot Resume Streams、Abort Breaks Resumable Streams
- assistant-ui:Resumable Streams
- worker、checkpoint、TTL 和多标签页部分是通用架构建议,并非声称上述 SDK 自动实现。