资讯动态

Web AI助手刷新恢复实战:会话状态、流式续传与幂等控制

发布时间:2026/10/22 10:24:38 来源:尧图企业网站定制
1. 页面刷新后对话消失问题到底出在哪做过 Web AI 助手的人大概率都遇到过这个场景用户跟助手聊了十几轮中间还上传了文件、触发了工具调用、生成了半截代码结果手一抖按了 F5页面白屏重来对话记录全没了。用户第一反应是这产品真烂第二反应是算了重新问一遍吧。而作为开发者你心里清楚——数据其实还在后端存着只是前端不知道该怎么把它捞回来。这个问题的核心不在存储而在恢复。存储是后端的事恢复是前端的事而恢复的难点在于AI 助手的会话不是一个静态列表而是一个有状态、有中间过程、有副作用的任务流。普通的聊天记录恢复很简单把消息数组拉回来渲染就行。但 AI 助手不一样它可能正在流式输出、可能有一个工具调用执行到一半、可能挂着一个待确认的操作。刷新之后这些进行中的状态该怎么处理才是真正让人头疼的地方。我前后在几个不同形态的 Web AI 助手上折腾过这套恢复机制踩过的坑从刷新后重复发送请求导致后端被刷爆到流式输出恢复后内容错位可以说把能踩的都踩了一遍。这篇就把这套刷新后恢复原任务的完整思路拆开讲从会话标识的设计、状态快照的存储、流式输出的断点续传到恢复时的幂等控制尽量把每个决策背后的原因说清楚。适合正在做 Web AI 助手、或者任何有长任务 刷新恢复需求的前端和后端同学参考。先明确一个前提这里说的恢复原任务不是简单地把历史消息重新显示出来而是让用户感觉刷新这件事没发生过——该在跑的还在跑该等的还在等该确认的还在那儿等着确认。这个体验目标决定了后面所有的技术选型。2. 会话标识与任务锚点恢复机制的地基2.1 为什么不能只靠内存里的会话 ID大多数人的第一版实现是这样的进入页面时生成一个sessionId存在内存变量里每次发消息带上它后端按这个 ID 存对话。刷新之后内存变量没了于是生成一个新的sessionId用户看到的就是一个全新的空对话。问题出在sessionId的生命周期和页面生命周期绑定了。要支持刷新恢复会话标识必须活得比页面久。最直接的做法是把它持久化到localStorage或者 URL 里。这两者各有取舍我实测下来的结论是URL 携带会话 ID比如/chat?sidabc123优点是刷新、分享、前进后退都能天然恢复浏览器历史记录本身就是一层状态管理。缺点是 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_offsetlen(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。这个按钮帮我省了大量的调试时间强烈推荐。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑