1. 页面刷新后对话消失,问题到底出在哪
做过 Web AI 助手的人大概率都遇到过这个场景:用户跟助手聊了十几轮,中间还上传了文件、触发了工具调用、生成了半截代码,结果手一抖按了 F5,页面白屏重来,对话记录全没了。用户第一反应是"这产品真烂",第二反应是"算了重新问一遍吧"。而作为开发者,你心里清楚——数据其实还在后端存着,只是前端不知道该怎么把它捞回来。
这个问题的核心不在"存储",而在"恢复"。存储是后端的事,恢复是前端的事,而恢复的难点在于:AI 助手的会话不是一个静态列表,而是一个有状态、有中间过程、有副作用的任务流。普通的聊天记录恢复很简单,把消息数组拉回来渲染就行。但 AI 助手不一样,它可能正在流式输出、可能有一个工具调用执行到一半、可能挂着一个待确认的操作。刷新之后,这些"进行中"的状态该怎么处理,才是真正让人头疼的地方。
我前后在几个不同形态的 Web AI 助手上折腾过这套恢复机制,踩过的坑从"刷新后重复发送请求导致后端被刷爆"到"流式输出恢复后内容错位",可以说把能踩的都踩了一遍。这篇就把这套"刷新后恢复原任务"的完整思路拆开讲,从会话标识的设计、状态快照的存储、流式输出的断点续传,到恢复时的幂等控制,尽量把每个决策背后的原因说清楚。适合正在做 Web AI 助手、或者任何有"长任务 + 刷新恢复"需求的前端和后端同学参考。
先明确一个前提:这里说的"恢复原任务",不是简单地把历史消息重新显示出来,而是让用户感觉刷新这件事没发生过——该在跑的还在跑,该等的还在等,该确认的还在那儿等着确认。这个体验目标决定了后面所有的技术选型。
2. 会话标识与任务锚点:恢复机制的地基
2.1 为什么不能只靠内存里的会话 ID
大多数人的第一版实现是这样的:进入页面时生成一个sessionId存在内存变量里,每次发消息带上它,后端按这个 ID 存对话。刷新之后内存变量没了,于是生成一个新的sessionId,用户看到的就是一个全新的空对话。
问题出在sessionId的生命周期和页面生命周期绑定了。要支持刷新恢复,会话标识必须活得比页面久。最直接的做法是把它持久化到localStorage或者 URL 里。这两者各有取舍,我实测下来的结论是:
- URL 携带会话 ID(比如
/chat?sid=abc123):优点是刷新、分享、前进后退都能天然恢复,浏览器历史记录本身就是一层状态管理。缺点是 URL 会变长,而且如果用户手动改了 ID 可能访问到别人的会话(需要后端做权限校验)。 - localStorage 存会话 ID:优点是 URL 干净,缺点是同一个浏览器多标签页会互相覆盖,用户开两个标签聊不同的话题就会串。
我的建议是两者结合:URL 里放会话 ID 作为主锚点,localStorage 里存一份"最近活跃会话"作为兜底。用户直接访问根路径时,从 localStorage 读最近会话跳转过去;带sid参数访问时以 URL 为准。这样既支持分享,又支持"回到上次"。
2.2 任务锚点:比会话 ID 更细的一层
光有会话 ID 还不够。一个会话里可能有多个"任务"——用户问了三个问题,前两个已经答完,第三个正在流式输出。刷新之后你要恢复的是"第三个任务正在进行"这个状态,而不是整个会话从头再来。
所以需要在会话之下再引入一层任务锚点(task anchor)。每个用户输入触发的处理流程,分配一个唯一的taskId,并记录它的状态:pending(已提交未开始)、streaming(正在输出)、tool_calling(工具调用中)、awaiting_confirm(等待用户确认)、done(已完成)、failed(失败)。
这层设计的关键在于:任务状态必须由后端权威维护,前端只做展示和恢复。我见过有团队把任务状态放在前端 Zustand/Redux 里,刷新就丢,然后靠"猜"来恢复,结果就是各种状态错乱。后端存一份任务状态表,前端刷新后拿着sessionId去问"这个会话里有没有未完成的任务",这是唯一可靠的做法。
2.3 会话 ID 的生成时机与幂等
还有一个容易忽略的细节:会话 ID 什么时候生成?如果等用户发第一条消息才生成,那用户打开页面还没说话就刷新,又回到空白。如果一进页面就生成,那会产生大量空会话。
我的做法是懒生成 + 立即持久化:用户第一次发送消息时生成sessionId,生成后立刻写入 URL(用history.replaceState不产生新历史记录)和 localStorage。这样空会话不会产生,而一旦有了会话,刷新就能恢复。这里用replaceState而不是pushState是有讲究的——如果用 push,用户每发一条消息 URL 就变一次,后退键会变成"回退到上一条消息",体验很怪。会话 ID 在会话生命周期内是不变的,所以只需要 replace 一次。
3. 状态快照:把"进行中"这件事存下来
3.1 快照要存什么,不存什么
恢复的核心是"状态快照"。但不是什么都要存,存多了浪费带宽和存储,存少了恢复不完整。我总结了一个判断标准:存"无法从其他数据推导出来"的状态。
具体来说,以下几类必须存:
| 状态类型 | 是否必须存 | 原因 |
|---|---|---|
| 消息列表(用户+助手) | 必须 | 这是对话的主体,无法推导 |
| 每个任务的 status | 必须 | 决定刷新后是继续等还是重新触发 |
| 流式输出的已生成内容 | 必须 | 否则刷新后已输出的部分丢失 |
| 工具调用的入参和结果 | 必须 | 有副作用,不能重放 |
| 当前选中的模型/参数 | 建议 | 影响后续消息的处理 |
| 前端滚动位置 | 可选 | 体验优化,可存 localStorage |
| 输入框草稿 | 建议 | 用户可能打了一半没发 |
而像"消息的渲染格式""markdown 解析结果"这类,属于纯前端派生数据,不需要存,刷新后重新渲染即可。
3.2 流式输出的快照策略:增量还是全量
流式输出(streaming)是恢复里最麻烦的部分。AI 助手的回复是一个 token 一个 token 吐出来的,如果每吐一个 token 就写一次数据库,写入压力会非常大;如果等全部吐完再写,那刷新时正在输出的内容就丢了。
我试过三种策略,最后选了增量追加 + 定期全量快照的混合方案:
- 增量追加:每收到一个 chunk,就 append 到后端的一个"流式缓冲区"里(可以用 Redis 的 list,或者数据库里一个
stream_buffer字段)。这个操作很轻,只是追加字符串。 - 定期全量快照:每 N 个 chunk 或者每 M 毫秒,把缓冲区的内容合并写一次正式的消息记录。这样即使缓冲区丢了,最多丢最后几秒的内容。
- 结束时落库:流式结束时,把完整内容写入正式消息表,清空缓冲区。
这里 N 和 M 的取值需要权衡。我实测下来,N 取 20 到 50 个 chunk、M 取 500ms 到 1s 比较合适。太小了写入频繁,太大了丢的内容多。具体还要看你的模型吐字速度,吐得快的模型可以适当调大。
注意:缓冲区一定要设过期时间。我见过有实现忘了给缓冲区设 TTL,结果用户中途关掉页面,缓冲区永远留在那里,越积越多。设个 10 分钟到 1 小时的 TTL,超时自动清理。
3.3 工具调用状态的持久化
工具调用(tool calling)是另一个重灾区。AI 助手调用一个工具,比如查数据库、发邮件、调外部 API,这个过程可能持续几秒到几十秒。如果用户在这期间刷新,恢复时该怎么办?
关键原则:工具调用一旦发起,就不能因为刷新而重放。因为工具调用往往有副作用——发邮件重放就发两封,扣款重放就扣两次。所以工具调用的状态必须持久化,并且要记录"是否已经执行"。
我的做法是给每个工具调用分配一个callId,状态机是:requested→executing→completed/failed。刷新恢复时:
- 如果状态是
requested:说明还没开始执行,可以安全地重新触发。 - 如果状态是
executing:说明正在执行,前端应该显示"处理中",并轮询后端查询结果,而不是重新发起。 - 如果状态是
completed/failed:直接展示结果。
这里有个坑:executing状态如果因为后端进程崩溃而永远卡住怎么办?需要加一个执行超时机制,超过阈值(比如 60 秒)还没变成completed,就标记为failed并允许用户重试。否则用户会看到一个永远转圈的界面。
4. 断点续传:让流式输出从断的地方接上
4.1 为什么不能简单地重新请求
最朴素的想法是:刷新后,如果发现有个任务在streaming,就重新发一次请求让模型重新生成。这个方案有两个致命问题:
第一,浪费。模型重新生成意味着重新消耗 token,用户已经看过的内容要重新看一遍,而且重新生成的内容可能和之前不一样,用户会觉得"怎么变了"。
第二,不一致。如果这个任务已经触发了工具调用,重新生成可能导致工具被调用两次。
所以正确的做法是断点续传:前端告诉后端"我已经收到了前 500 个字符,请从第 501 个字符开始继续发给我"。后端从缓冲区里找到对应位置,继续推送剩余内容。
4.2 基于偏移量的续传实现
实现断点续传的关键是给流式内容一个单调递增的偏移量。每生成一个 chunk,就给它一个序号(或者用累计字符数作为偏移量)。前端记录自己收到的最大偏移量,刷新后带上这个偏移量请求续传。
后端收到续传请求后,逻辑大致是:
def resume_stream(task_id, client_offset): buffer = get_stream_buffer(task_id) if buffer is None: # 缓冲区已过期,任务可能已完成或已失败 task = get_task(task_id) if task.status == 'done': return {'type': 'completed', 'content': task.content} else: return {'type': 'expired'} # 从客户端偏移量之后继续推送 remaining = buffer[client_offset:] for chunk in remaining: yield chunk # 如果任务还在进行,继续等待新内容 if task.status == 'streaming': for chunk in wait_for_new_chunks(task_id, from_offset=len(buffer)): yield chunk前端这边,收到续传的内容后,要追加到已有的消息内容后面,而不是替换。这里要注意去重——如果网络抖动导致某个 chunk 重复推送,前端要根据偏移量去重。
4.3 续传的边界情况处理
实际跑起来,续传会遇到几种边界情况,每一种我都踩过:
情况一:缓冲区过期但任务还在跑。比如用户刷新后隔了半小时才回来,缓冲区 TTL 到了被清理,但任务其实还在后端跑。这时候续传拿不到历史内容,只能从当前时刻开始接收。我的处理是:如果拿不到缓冲区,就返回一个特殊标记,前端提示"部分历史内容已过期,以下为后续内容",然后从当前开始接收。虽然不完美,但比直接报错好。
情况二:任务在刷新期间完成了。用户刷新时任务还在 streaming,等页面加载完去查询时任务已经 done 了。这时候不需要续传,直接把完整结果拉回来渲染即可。所以续传接口要能处理"任务已完成"的情况,返回完整内容。
情况三:多个标签页同时续传。用户在两个标签页打开了同一个会话,都触发了续传。这时候后端要能处理多个订阅者,每个订阅者有自己的偏移量。用发布订阅模式(比如 Redis Pub/Sub)比较合适,每个连接独立维护偏移量。
情况四:续传过程中又刷新了。这个其实不算问题,因为每次续传都是独立的,前端重新带上最新的偏移量即可。但要保证偏移量是持久化的,不能只存在内存里。我一般把偏移量存在 sessionStorage 里,刷新后能读到。
5. 幂等控制:别让刷新变成"再发一遍"
5.1 重复提交是怎么发生的
标题里说"刷新后别再发一遍",这个"再发一遍"指的就是重复提交。它通常发生在这样的时序里:
- 用户点击发送,前端发起请求。
- 请求还在路上,用户刷新了页面。
- 页面重新加载,前端从 localStorage 读到"有个待发送的消息",于是又发了一次。
- 后端收到两次相同的请求,处理了两遍。
或者更隐蔽的一种:
- 用户发送消息,后端开始处理。
- 前端因为网络问题没收到响应,超时后自动重试。
- 后端收到两次,处理了两遍。
这两种情况的本质都是同一个逻辑操作被执行了多次。解决它的标准武器是幂等键(idempotency key)。
5.2 用幂等键锁住重复请求
幂等键的思路很简单:前端为每个"逻辑操作"生成一个唯一 ID(比如 UUID),随请求一起发给后端。后端收到请求后,先查这个 ID 有没有处理过:
- 没处理过:正常处理,处理前先把 ID 记下来(可以放 Redis,设个过期时间)。
- 处理过了:直接返回上次的结果,不重复处理。
关键在于这个幂等键的生成时机。它必须在用户点击发送的那一刻生成,并且持久化下来,这样刷新后才能用同一个键去查询"我上次那个请求处理得怎么样了"。
// 发送消息时 function sendMessage(content) { // 先看有没有未完成的请求 const pending = getPendingRequest(); if (pending) { // 有未完成的,先查询状态而不是重新发送 return queryRequestStatus(pending.idempotencyKey); } const key = generateUUID(); savePendingRequest({ idempotencyKey: key, content }); return fetch('/api/chat', { method: 'POST', headers: { 'Idempotency-Key': key }, body: JSON.stringify({ content }) }).then(res => { clearPendingRequest(); return res; }); }刷新后,前端检查有没有pendingRequest,如果有,就带着幂等键去查询状态,而不是重新发送。后端根据幂等键返回"处理中"或"已完成"的结果。
5.3 幂等键的过期与清理
幂等键不能永久保存,否则存储会无限增长。但过期时间设多长有讲究:
- 太短:用户刷新后隔了一会儿才回来,键过期了,查询不到,又变成重复发送。
- 太长:存储压力大。
我的经验值是24 小时。因为绝大多数用户不会隔一天才回来处理一个未完成的任务,而 24 小时的存储成本可以接受。如果对存储特别敏感,可以用 1 小时,但要配合前端的"超时后提示用户"逻辑——超过一定时间还没结果,就告诉用户"任务可能已失效,请重新发送"。
另外,幂等键的记录里最好带上结果引用,而不只是"处理过了"这个标记。这样重复请求进来时能直接返回上次的结果,而不是返回一个空响应让前端干等。
6. 恢复流程的完整时序与实测坑点
6.1 一次完整恢复的时序
把前面的东西串起来,一次刷新恢复的完整流程是这样的:
- 页面加载:从 URL 或 localStorage 读取
sessionId。 - 拉取会话:用
sessionId请求后端,拿到消息列表和任务状态列表。 - 渲染历史:把已完成的消息渲染出来。
- 检查未完成任务:遍历任务状态,找出
streaming、tool_calling、awaiting_confirm的任务。 - 恢复流式任务:对
streaming任务,读取本地记录的偏移量,发起续传请求,把新内容追加到对应消息上。 - 恢复工具调用:对
tool_calling任务,显示"处理中",轮询后端查询结果。 - 恢复待确认:对
awaiting_confirm任务,把确认按钮重新渲染出来。 - 恢复草稿:从 localStorage 读取输入框草稿,填回去。
这个流程里,第 4 步到第 7 步是核心,也是最容易出问题的地方。
6.2 实测中遇到的坑
坑一:恢复顺序错了导致内容错位。我一开始是先恢复流式内容再渲染历史消息,结果流式内容被追加到了错误的消息上。正确顺序必须是先渲染历史消息、建立好消息和任务的映射关系,再往对应的消息上追加流式内容。这个顺序不能反。
坑二:偏移量对不上。前端记录的偏移量和后端缓冲区的偏移量如果基准不一致(比如前端按字符数、后端按 chunk 数),续传就会错位。解决办法是统一用字符数作为偏移量,因为字符数是跨语言、跨编码都一致的度量。
坑三:恢复时重复触发工具调用。这个前面提过,根因是没区分"未执行"和"执行中"。一定要在恢复逻辑里严格判断工具调用的状态,executing状态绝对不能重新触发。
坑四:多标签页互相干扰。用户在两个标签页打开同一会话,一个标签页恢复了流式任务,另一个也去恢复,导致内容重复。解决办法是用BroadcastChannel或者 localStorage 的事件通知,让标签页之间协调——只有一个标签页负责恢复流式任务,其他标签页只做展示。
坑五:恢复后自动滚动到底部失效。流式内容追加后,页面应该自动滚到底部。但如果恢复的内容很多,一次性追加会导致滚动跳动。我的做法是恢复时先不滚动,等内容追加完再平滑滚到底部,或者用requestAnimationFrame分批追加。
6.3 一个容易被忽略的细节:时间戳
恢复时,消息的时间戳要保留原始时间,而不是用恢复时的时间。否则用户会看到"我明明十分钟前问的,怎么显示刚刚"。后端存消息时一定要存原始时间戳,前端渲染时用这个时间戳,不要用Date.now()。
7. 存储选型与性能取舍
7.1 会话数据存哪里
会话数据的存储选型直接影响恢复的速度和可靠性。常见的几种选择:
| 存储方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 关系型数据库 | 中小规模、需要事务 | 成熟稳定、查询灵活 | 流式高频写入压力大 |
| 文档数据库 | 消息结构灵活 | schema 自由、写入快 | 事务弱 |
| Redis + 数据库 | 高频读写、需要缓冲 | 快、适合流式缓冲 | 内存成本、需持久化策略 |
| 对象存储 | 大内容、归档 | 便宜、容量大 | 延迟高、不适合实时 |
我的推荐是混合方案:正式消息存关系型或文档数据库,流式缓冲区用 Redis,大文件(比如用户上传的附件)用对象存储。这样各取所长,成本也可控。
7.2 恢复性能的优化
恢复时要拉取整个会话的消息,如果会话很长(几百轮对话),一次性拉取会很慢。优化手段有几个:
- 分页加载:只拉最近 N 条,用户往上滚再加载更多。但要注意,未完成的任务可能在很前面,所以任务状态要单独拉,不受分页影响。
- 消息摘要:对很老的消息做摘要,减少传输量。但这个会影响用户查看历史,要谨慎。
- 增量同步:记录客户端最后同步的位置,只拉取新增的消息。适合频繁刷新的场景。
我实测下来,分页加载 + 任务状态单独拉是最实用的组合。消息列表分页,但未完成任务列表永远全量拉取(因为未完成的任务通常不多),这样既保证了性能,又不会漏掉需要恢复的任务。
7.3 存储成本的控制
流式缓冲区是存储成本的大头,因为它是高频写入的。控制成本的手段:
- TTL 自动清理:前面说过,必须设。
- 压缩:缓冲区内容可以压缩存储,尤其是文本内容压缩率很高。
- 分级存储:活跃会话的缓冲区放内存,不活跃的落盘。
这里有个经验:不要为了省存储而牺牲恢复体验。我见过有团队为了省钱把缓冲区 TTL 设成 1 分钟,结果用户稍微一刷新就恢复不了,投诉一堆。存储成本相比用户体验,优先级要低。
8. 写在最后的一些实操体会
这套恢复机制我从第一版做到现在,前后迭代了四五次,最大的体会是:恢复的复杂度不在于技术本身,而在于状态的管理。会话、任务、流式内容、工具调用,每一层都有自己的状态,这些状态之间还有依赖关系。如果一开始没有把状态模型设计清楚,后面就是打补丁,越补越乱。
我的建议是,动手写代码之前,先把状态机画出来——会话有哪些状态、任务有哪些状态、它们之间怎么流转、刷新时每个状态该怎么处理。这个图想清楚了,代码就是水到渠成的事。反过来,如果状态机没想清楚就开写,大概率要推倒重来。
另外一个体会是,幂等这件事要贯穿始终。不只是发送消息要幂等,恢复流式内容要幂等,工具调用要幂等,甚至渲染都要考虑幂等(重复渲染同样的内容不能出问题)。在分布式和异步的环境里,任何操作都可能被执行多次,幂等是保证正确性的底线。
最后分享一个小技巧:在开发阶段,可以加一个"模拟刷新"的调试按钮,点击后强制走一遍恢复流程,但不清空内存状态。这样能快速验证恢复逻辑,不用每次都真的按 F5。这个按钮帮我省了大量的调试时间,强烈推荐。