
1. 问题到底出在哪刷新丢任务的本质做过 Web AI 助手的人大概率都遇到过这个场景用户跟助手聊了十几轮中间还上传了文件、触发了长任务结果手一抖按了 F5页面白屏重载对话记录没了正在跑的任务也断了。用户第一反应就是再发一遍于是后台多了一条重复请求计费翻倍日志里两条几乎一样的会话并排躺着排查起来非常难受。这个问题的核心不是前端没存聊天记录这么简单。真正要解决的是三件事会话状态的持久化、进行中任务的恢复、重复提交的拦截。三者缺一不可只做第一件用户刷新后看到历史记录但任务还是断的只做第二件任务恢复了但用户不知道刚才聊到哪只做第三件拦截住了重复请求但用户一脸懵以为自己的操作没生效。我把它拆成一个更工程化的说法Web AI 助手需要在无状态的前端和有状态的后端任务之间建立一条可重建的映射关系。刷新是前端的一次失忆但后端其实还记得关键是前端刷新后能不能拿着一个稳定的标识把后端记得的东西重新拉回来并且判断出这个任务还在跑不需要重发。适合读这篇的人有三类正在做 AI 对话类产品的后端和前端做任务型系统长任务、异步任务想加恢复能力的以及被用户重复提交这个问题折磨过的同学。下面我按实际落地的顺序把设计思路、关键细节、完整实现和踩坑经验一次讲清楚。2. 整体设计思路让刷新变成一次重连而不是重来2.1 核心思路会话 ID 任务 ID 双锚点我的做法是给每次对话分配一个稳定的conversation_id给每次用户提交分配一个稳定的task_id。这两个 ID 在用户第一次发消息时由前端生成或者由后端返回后前端持久化之后无论刷新多少次前端都拿这两个 ID 去后端认领状态。为什么是两个 ID 而不是一个因为会话和任务是一对多关系。一个会话里用户可能连续发好几条消息每条消息对应一个任务。如果只用一个 ID刷新后你没法区分我要恢复的是整个会话还是我要恢复的是某一条正在跑的任务。分开之后逻辑就清晰了会话 ID 负责拉历史消息任务 ID 负责拉进行中任务的状态。这里有个关键决策ID 由谁生成。我倾向于前端生成task_id用 UUID 或时间戳加随机串原因是前端在发请求之前就能确定这个 ID可以立刻写进本地存储即使请求还没到后端、页面就刷新了这个 ID 也不会丢。如果等后端返回 ID 再存中间有个时间窗口刷新就断了。这是很多人第一次做会踩的坑。2.2 状态放哪本地存储 服务端快照前端本地存储localStorage或sessionStorage只存指针也就是conversation_id、当前task_id、以及最近一次的消息游标。真正的消息内容、任务进度、结果全部放服务端。为什么不把消息也存本地两个原因。一是多端一致性用户在电脑上聊到一半手机上打开要能接着看本地存储做不到。二是数据可信度本地存储可以被用户清掉、被浏览器策略回收把关键状态放本地等于把可靠性交给运气。本地只做我上次聊的是哪个会话这种轻量指针丢了也能通过其他方式找回比如登录态下的会话列表。服务端这边任务状态我建议单独建一张表或者一个状态存储字段至少包含task_id、conversation_id、statuspending/running/done/failed、progress、result_ref、created_at、updated_at。任务执行器每推进一步就更新一次状态前端轮询或通过长连接订阅这个状态。2.3 恢复流程刷新后先查再决定刷新后的完整流程是这样的页面加载 → 从本地存储读conversation_id和task_id→ 调后端接口查这个任务的状态 → 根据状态决定行为。状态是done直接把结果渲染出来不重发。状态是running恢复进度条重新订阅进度不重发。状态是failed提示用户失败给一个重试按钮由用户决定是否重发。查不到这个任务说明任务从未成功创建或者已被清理这时才允许用户重新提交。这套流程的价值在于把是否重发的决策权从用户的肌肉记忆转移到了系统状态上。用户不需要知道背后发生了什么他刷新完看到任务还在跑自然就不会再发一遍。2.4 为什么不用防抖或按钮禁用糊弄过去有人会说重复提交用前端防抖、按钮点完就禁用不就行了。这只解决了同一页面内快速连点解决不了刷新场景。刷新之后页面状态全没了按钮又变成可点的防抖计时器也归零了。所以防重必须建立在服务端可识别的幂等键上也就是前面说的task_id。前端防抖是锦上添花服务端幂等才是底线。3. 核心细节解析几个决定成败的关键点3.1 幂等键怎么设计才靠谱task_id作为幂等键服务端收到创建任务的请求时先拿这个 ID 去查如果已经存在且状态不是失败就直接返回已有任务的状态不再新建。这就是标准的幂等语义。设计上有几个细节要注意。第一task_id的生成要足够随机避免碰撞UUID v4 是稳妥选择别用自增数字或者纯时间戳多用户并发下会撞。第二幂等键的有效期要设比如 24 小时过期后同样的 ID 可以重新创建避免历史 ID 永久占用存储。第三幂等判断要放在事务里或者用原子操作两个并发请求同时带着同一个task_id进来必须保证只有一个能创建成功另一个走已存在分支。用数据库唯一索引是最省事的做法插入冲突就捕获异常返回已有记录。提示幂等键不要用消息内容的哈希来代替。用户可能故意发两条一样的内容那是两次合法请求用内容哈希会把第二次误判成重复。3.2 任务状态机要闭环任务状态不能只有跑着和跑完两种中间态和异常态都要有。我一般用这几个状态pending已创建未开始、running执行中、done成功、failed失败、cancelled用户主动取消。状态流转必须是单向的、可审计的每次变更记录时间戳。为什么强调闭环因为刷新恢复时前端要根据状态做不同处理如果状态定义模糊比如失败和取消混在一起用户刷新后看到任务失败但其实是他自己取消的体验就很怪。状态机清晰恢复逻辑才能写得干净。3.3 进度恢复轮询还是长连接任务进度怎么让前端知道有两条路。轮询简单前端每隔一两秒调一次状态接口实现成本低缺点是实时性差一点、请求量大一点。长连接WebSocket 或 SSE实时性好但刷新后要重新建连、重新订阅复杂度高一些。我的经验是短任务用轮询长任务用 SSE。SSE 比 WebSocket 更适合这种服务端单向推状态的场景而且它基于 HTTP刷新后重连的逻辑更自然。不管用哪种关键都是订阅这个动作要能根据task_id重建而不是依赖内存里的连接对象。刷新后连接断了没关系拿着task_id重新订阅就行。3.4 消息历史的游标设计会话历史如果一次全量拉消息多了会很慢。我一般用游标分页本地存一个已加载到哪条消息的游标刷新后先拉最近 N 条用户往上翻再拉更早的。游标用消息的created_at加id组合避免同一毫秒多条消息导致分页错乱。这里有个容易忽略的点进行中任务的那条用户消息和助手占位消息要一起持久化。很多实现只存了已完成的消息任务跑到一半刷新用户消息还在但助手那条空占位没了恢复出来就是用户问了一句下面什么都没有很突兀。正确做法是用户提交时就把用户消息和助手占位消息一起写库助手消息状态标记为生成中任务完成后更新内容。4. 完整实操从提交到恢复的全链路实现4.1 前端提交时的处理用户点发送前端做这几件事顺序不能乱生成task_idUUID v4。把task_id和conversation_id写入localStorage。乐观渲染用户消息和助手占位消息。带着task_id发创建任务请求。function submitMessage(conversationId, content) { const taskId crypto.randomUUID(); // 先落本地再发请求顺序很关键 localStorage.setItem(active_task_id, taskId); localStorage.setItem(active_conversation_id, conversationId); renderUserMessage(content); const placeholder renderAssistantPlaceholder(); return fetch(/api/tasks, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ task_id: taskId, conversation_id: conversationId, content }) }).then(res res.json()) .then(data subscribeTask(taskId, placeholder)); }注意第 2 步和第 4 步的顺序。如果先发请求再存本地请求发出到响应回来之间刷新task_id就丢了后端创建了任务但前端不知道恢复不了。先存本地最坏情况是本地有个 ID 但后端没这个任务恢复时查不到走允许重发分支这是可接受的。4.2 后端创建任务的幂等实现后端收到请求核心是先查后插插入冲突兜底。def create_task(task_id, conversation_id, content): existing task_repo.get(task_id) if existing and existing.status ! failed: return existing # 幂等命中直接返回 try: task task_repo.insert( task_idtask_id, conversation_idconversation_id, statuspending, contentcontent ) except UniqueViolation: # 并发下另一个请求先插入了返回已有的 return task_repo.get(task_id) enqueue(task_id) # 丢进任务队列 return taskUniqueViolation这个兜底很重要。高并发下先查后插之间有窗口两个请求可能都查到不存在然后都去插靠数据库唯一索引挡住第二个。没有这个兜底幂等就是纸糊的。4.3 刷新后的恢复逻辑页面初始化时执行恢复流程async function restoreOnLoad() { const taskId localStorage.getItem(active_task_id); const conversationId localStorage.getItem(active_conversation_id); if (conversationId) { await loadHistory(conversationId); } if (!taskId) return; const task await fetchTask(taskId); if (!task) { // 后端没有这个任务允许重发 localStorage.removeItem(active_task_id); return; } if (task.status running || task.status pending) { subscribeTask(taskId); // 重新订阅不重发 } else if (task.status done) { renderResult(task); localStorage.removeItem(active_task_id); } else if (task.status failed) { showRetryButton(taskId); } }这段逻辑是整个方案的心脏。它把刷新从一次破坏性操作变成了无害的重连。用户刷新后要么看到历史要么看到任务继续跑要么看到失败提示唯独不会看到需要重新发送。4.4 任务执行器的状态更新任务执行器每完成一个阶段就更新状态这是恢复能拿到进度的前提。def run_task(task_id): task_repo.update(task_id, statusrunning, progress0) try: for i, step in enumerate(steps): result step.run() progress int((i 1) / len(steps) * 100) task_repo.update(task_id, progressprogress) task_repo.update(task_id, statusdone, result_refsave_result(result)) except Exception as e: task_repo.update(task_id, statusfailed, errorstr(e))状态更新要及时落库不能攒着最后一起写。任务跑到 80% 崩了如果状态没落库恢复时前端看到的是pending用户以为还没开始实际已经跑了一大半体验割裂。4.5 清理策略别让本地存储变成垃圾场任务完成后前端要把active_task_id清掉否则下次刷新会去查一个已经完成的老任务。但conversation_id要保留因为用户可能还想继续这个会话。这个清理时机要卡准任务done或failed且用户已经看到结果之后再清别一收到完成通知就清万一渲染失败用户还得刷新那时 ID 已经没了。5. 常见问题与排查技巧实录5.1 刷新后任务消失了最常见的原因是task_id没落本地就发了请求或者落本地的 key 和读本地的 key 不一致。排查方法打开开发者工具看localStorage里有没有active_task_id再看网络面板里创建任务的请求体里task_id是什么两者对不上就是前端 bug。还有一种情况是后端任务表有 TTL任务还没跑完就被清理了这时查不到任务前端走了允许重发分支用户感觉任务消失了。TTL 要设得比最长任务时长还长。5.2 重复任务还是出现了幂等没生效通常是三个原因。一是幂等判断没放在事务里并发下漏了。二是task_id每次请求都重新生成那幂等键就失去意义了检查前端是不是在重试逻辑里又调了一次crypto.randomUUID()。三是幂等键的有效期太短用户隔了一会儿重发旧记录过期了又创建了新的。把有效期设成 24 小时基本能覆盖绝大多数场景。5.3 进度条刷新后归零这是状态没落库或者落库不及时导致的。检查任务执行器是不是只在开始时写了一次状态中间没更新。另外前端订阅重建后要先用查到的progress初始化进度条再等后续推送别一上来就渲染成 0。5.4 多标签页同时打开互相干扰用户在两个标签页打开同一个会话一个在跑任务另一个刷新localStorage是共享的可能读到同一个task_id。这本身不算 bug两个标签页都订阅同一个任务都能看到进度是合理的。但如果用户在标签页 A 取消任务标签页 B 不知道就会显示不一致。解决办法是用storage事件监听本地存储变化或者干脆用 BroadcastChannel 在标签页之间同步任务状态。5.5 常见问题速查表现象可能原因排查方向刷新后任务消失task_id 未落本地 / 任务被 TTL 清理查 localStorage 和任务表重复任务出现幂等未生效 / task_id 重新生成查幂等逻辑和前端生成时机进度归零状态未及时落库查执行器更新频率多标签不一致本地存储共享无同步加 storage 事件或 BroadcastChannel恢复后消息错位占位消息未持久化查提交时是否写入占位消息5.6 几个我踩过的坑第一个坑是把task_id存在内存变量里。想着刷新就刷新吧结果刷新后变量没了恢复逻辑根本拿不到 ID。本地存储是必须的别偷懒。第二个坑是恢复时重新发了一遍请求。当时逻辑写成了刷新后如果任务在跑就重新提交一次让它继续结果后端收到两个任务一个在跑一个新建资源翻倍。恢复的正确姿势是查状态 重新订阅绝不是重新提交。第三个坑是状态更新用了最终一致性。任务状态写进缓存缓存异步刷到数据库刷新时读到的是旧状态。任务型系统的状态更新建议走强一致直接写库别为了那点性能牺牲正确性。第四个坑是忘了处理任务创建成功但入队失败。任务记录插进去了但丢进队列那一步挂了任务永远停在pending。恢复时前端看到pending一直转圈。解决办法是加一个超时扫描pending超过一定时间没变成running就标记失败让用户能重试。6. 进阶优化让恢复体验更顺滑6.1 断线重连的退避策略SSE 或 WebSocket 断线后重连别用固定间隔猛冲用指数退避1 秒、2 秒、4 秒、8 秒封顶 30 秒。这样既保证恢复及时又不会在服务端抖动时雪上加霜。重连成功后第一件事是拉一次全量状态把断线期间错过的进度补上。6.2 任务结果的引用式存储任务结果可能很大比如生成的文档、图片别直接塞进任务状态表。状态表只存一个result_ref指向对象存储或结果表。恢复时前端拿到result_ref再去拉内容。这样状态表保持轻量查询快也方便结果单独做缓存和 CDN。6.3 会话级别的恢复入口除了自动恢复最近一个任务我还会在会话列表里给每个有进行中任务的会话加一个小标记。用户切到别的会话再切回来也能看到那个会话还在跑。这需要在会话列表接口里带上是否有进行中任务的字段实现成本不高但体验提升明显。6.4 用户主动取消的处理用户点取消前端发一个取消请求后端把任务状态置为cancelled执行器在下一个检查点退出。取消后本地active_task_id要清掉否则刷新后又会去查这个已取消的任务。取消是幂等的重复取消返回成功即可别报错。7. 一点个人体会这套方案我在几个项目里都落地过最大的感受是恢复能力的本质是状态外置。只要把该记的状态记在服务端前端刷新就只是一次普通的重新加载没有任何特殊性。反过来如果状态散落在前端内存、组件状态、闭包里那刷新就是灾难你怎么补都补不干净。另一个体会是幂等键这个东西早加早省心。我见过太多项目一开始不做幂等等线上出现重复计费、重复任务了才回头补那时候历史数据已经脏了补起来非常痛苦。在第一次写创建任务接口的时候就把task_id和唯一索引加上成本几乎为零。最后分享一个小技巧调试恢复逻辑时别老老实实按 F5用开发者工具的网络限速把请求拖慢然后在请求飞行途中刷新能稳定复现各种边界情况。正常网速下很多竞态问题一闪而过限速之后全暴露出来改起来有的放矢。