Web端AI助手刷新恢复实战:任务持久化与幂等去重方案
1. 刷新页面后那个“正在思考”的AI助手为什么失忆了做过Web端AI助手的人大概率都遇到过这个场景用户输入一段长问题助手开始流式输出结果用户手一抖按了F5或者网络抖动导致页面重载回来之后对话框空空如也刚才那个跑到一半的任务彻底没了。用户只能重新打字重新等待体验断崖式下跌。更糟的是如果这个任务背后已经消耗了模型调用额度、已经触发了工具链、甚至已经写入了部分结果那这次刷新带来的不只是体验问题还有实打实的资源浪费和数据不一致风险。这个问题的本质是Web应用的请求-响应模型与AI任务的长时运行特性之间的错配。传统Web请求是短生命周期的一次请求对应一次响应页面刷新就意味着上下文清零。但AI助手的一次任务往往要跑几秒到几分钟中间还涉及流式输出、多轮工具调用、状态机流转。页面刷新相当于把客户端和服务端之间的“会话线”剪断了而任务本身可能还在服务端跑着或者已经跑完但结果没人接收。所以“刷新后别再发一遍”这个标题核心要解决的是三件事任务状态的持久化、刷新后的任务恢复、以及避免重复提交。它适合所有在做Web端AI对话产品、智能体平台、在线协作工具的开发者参考不管你是用React、Vue还是原生JS不管后端是Node、Python还是Java这套思路都能落地。接下来我会从任务模型设计、状态存储、恢复流程、幂等控制几个层面把我在实际项目里踩过的坑和验证过的方案完整拆开讲。2. 把“一次对话”拆成可恢复的任务单元2.1 为什么不能只靠前端state保存对话很多人第一反应是把对话内容存在localStorage里不就行了刷新后读出来渲染。这个做法在纯文本聊天场景下勉强能用但放到AI助手场景就会暴露三个致命问题。第一流式输出的中间态无法靠前端快照还原。AI助手的输出是逐token到达的如果用户在第37个token时刷新localStorage里可能只存到了第30个token剩下7个token对应的服务端生成结果就丢了。你可能会说那就等输出完再存但用户刷新往往就发生在输出过程中等输出完再存等于没解决。第二工具调用和副作用无法回滚。AI助手在执行任务时可能调用了搜索、数据库写入、文件生成等操作。这些副作用发生在服务端前端localStorage根本不知道。刷新后如果重新发起任务这些副作用会重复执行造成数据污染。第三多标签页和跨设备场景下状态冲突。用户在A标签页发起了任务又在B标签页刷新localStorage是共享的两个页面会互相覆盖状态。如果用户换了设备localStorage更是完全隔离。所以正确的做法是前端只保存一个任务ID和轻量级展示状态真正的任务状态由服务端持久化刷新后通过任务ID去服务端拉取完整状态。这就是把“对话”拆成“任务单元”的核心思路。2.2 任务单元需要携带哪些字段一个可恢复的AI任务单元至少需要包含以下字段。我在实际项目里用的是下面这张表的结构你可以根据业务增减字段名类型说明task_idstring全局唯一任务ID创建时生成贯穿整个生命周期session_idstring会话ID用于把多个任务归到同一对话下user_idstring用户标识用于权限校验和跨设备恢复statusenumpending / running / streaming / completed / failed / cancelledinput_payloadjson用户原始输入包括文本、附件引用、参数配置output_buffertext已生成的输出内容流式过程中持续追加tool_callsjson工具调用记录含调用参数、返回结果、时间戳created_attimestamp任务创建时间updated_attimestamp最后更新时间用于判断任务是否僵死client_tokenstring客户端幂等令牌防止重复提交versionint乐观锁版本号防止并发写覆盖这里有几个字段的设计意图需要展开说。status用枚举而不是布尔值是因为AI任务的状态流转比“成功/失败”复杂得多。streaming状态表示正在流式输出这个状态下刷新恢复逻辑是“继续接收剩余流”completed状态下刷新恢复逻辑是“直接渲染完整结果”。两种恢复路径完全不同所以状态必须区分。output_buffer用追加写而不是覆盖写是为了配合流式场景。每次收到新token就append到buffer末尾同时更新updated_at。这样即使刷新服务端buffer里已经有完整的前半段恢复时直接从buffer末尾继续推流即可。client_token是幂等控制的关键。前端在发起任务前生成一个随机token随请求一起发送。服务端收到请求后先查这个token是否已存在存在就直接返回已有任务ID不重复创建。这样即使用户快速点两次提交或者刷新后自动重试也不会产生两个任务。2.3 任务状态机的流转设计任务状态不是随便改的需要一套明确的状态机来约束。我用的状态流转规则是这样的创建任务时初始状态为pending表示已入库但还没开始执行。调度器拾取任务后转为running此时开始调用模型。模型开始返回第一个token时转为streaming并持续追加output_buffer。模型正常结束且所有工具调用完成后转为completed。任意环节抛出异常转为failed并记录错误信息。用户主动取消或超时未更新转为cancelled。关键约束是只有running和streaming状态的任务才允许被恢复推流completed状态只允许读取结果failed和cancelled状态只允许展示错误或取消原因。这个约束避免了恢复逻辑去处理不该处理的状态减少边界情况。另外我加了一个心跳检测running和streaming状态的任务如果updated_at超过30秒没有更新就被标记为疑似僵死由后台巡检任务决定是重试还是置为failed。这个机制解决的是服务端进程崩溃导致任务永远卡在running的问题。3. 刷新恢复的完整链路从页面重载到流式续传3.1 前端启动时的恢复探测页面加载时前端第一件事不是渲染空对话框而是执行恢复探测。具体流程是从URL参数或localStorage中读取最近一次活跃的task_id如果存在就调用服务端的任务查询接口获取任务当前状态。这里有个细节task_id的存储位置决定了恢复的粒度。如果存在localStorage恢复的是“这个浏览器上最近的任务”如果存在URL query参数里恢复的是“这个链接对应的任务”可以分享给其他人或跨设备打开。我在项目里两个都用了localStorage存最近任务用于自动恢复URL参数用于分享和书签场景。恢复探测的接口设计要尽量轻量只返回status、output_buffer长度、updated_at这几个字段不要一上来就拉全量tool_calls。因为探测阶段只需要判断“要不要恢复”不需要渲染全部内容。等确定要恢复后再调详情接口拉全量数据。// 恢复探测的伪代码 async function probeRecovery() { const taskId getTaskIdFromStorage() || getTaskIdFromUrl(); if (!taskId) return null; const probe await fetch(/api/task/${taskId}/probe); const { status, bufferLength, updatedAt } await probe.json(); if (status streaming || status running) { return { taskId, mode: resume, bufferLength }; } if (status completed) { return { taskId, mode: render }; } return { taskId, mode: show_error, status }; }3.2 服务端如何支持“断点续流”服务端要支持恢复核心是把流式输出从“一次性响应”改成“可重放的缓冲流”。具体做法是模型每生成一个token除了推给当前连接还要追加写入output_buffer持久化存储。同时维护一个buffer的版本号或偏移量。当客户端带着task_id和已接收的bufferLength来恢复时服务端做三件事校验任务状态是否为streaming或running。从output_buffer的第bufferLength个字符开始把剩余内容推给客户端。如果任务还在生成中继续实时推流如果任务已结束推完剩余内容后发送结束标记。这里的关键是偏移量对齐。客户端记录的bufferLength必须是服务端buffer的准确偏移否则会出现内容重复或缺失。我踩过的坑是前端用字符数记录偏移但服务端buffer里可能包含多字节字符字符数和字节数不一致导致偏移错位。后来统一改成用UTF-8字节偏移前后端都按字节计算问题才解决。另一个坑是流式协议的选择。SSEServer-Sent Events天然支持断线重连和Last-Event-ID非常适合这个场景。WebSocket虽然双向但重连后需要自己实现消息序号对齐。如果团队没有强双向需求我建议优先用SSE配合event id做偏移量浏览器原生支持自动重连。3.3 恢复时的UI状态同步前端恢复时UI不能简单地把已有内容渲染出来就完事还要处理几个状态同步问题。滚动位置如果output_buffer已经很长恢复后应该滚动到用户刷新前的位置而不是顶部或底部。我的做法是在刷新前把滚动位置百分比存到sessionStorage恢复后按比例还原。输入框状态如果任务还在streaming输入框应该保持禁用或显示“正在生成中”避免用户重复提交。如果任务已完成输入框恢复可用。工具调用展示如果任务涉及工具调用恢复后要把已完成的工具调用记录渲染出来让用户看到“助手已经查了资料、已经写了文件”这些中间步骤而不是只看到最终文本。错误态处理如果恢复探测发现任务是failed要展示失败原因和重试按钮重试时用新的client_token创建新任务而不是复用旧task_id。4. 幂等与去重刷新后为什么不能重新发一遍4.1 重复提交的三种典型场景“刷新后别再发一遍”这句话里的“发一遍”其实对应三种不同的重复提交场景每种的处理策略不一样。第一种是用户手动重复提交。用户刷新后看到输入框空了以为任务没发出去又打了一遍同样的内容点提交。这种靠前端状态恢复就能避免——恢复后输入框里应该保留原始输入或者至少显示“上次任务已恢复”的提示。第二种是前端自动重试导致的重复。有些前端框架在请求失败时会自动重试如果刷新时正好有个请求在途重试逻辑可能又发一次。这种靠client_token幂等控制解决。第三种是多标签页并发提交。用户在两个标签页都打开了同一个会话一个标签页刷新后自动恢复并重发另一个标签页也在操作。这种靠服务端的任务锁和session级别的互斥来控制。4.2 client_token的生成与校验时机client_token的生成时机很关键。我试过两种方案一种是在页面加载时生成一个固定token整个会话周期都用它另一种是每次提交前生成新token。第一种方案的问题是如果用户连续提交两个不同问题第二个会被误判为重复。第二种方案更合理但要注意刷新恢复场景下不能生成新token。正确的做法是token在用户点击提交时生成随任务一起持久化。刷新恢复时从已恢复的任务里读取token不生成新的。只有当用户明确要发起新任务时才生成新token。这样既保证了正常提交的幂等又不会把恢复误判为新提交。服务端校验时用user_id client_token做唯一索引。收到请求先查这个组合是否存在存在就返回已有task_id和当前状态不存在才创建新任务。这个查询要加缓存避免每次提交都打数据库。4.3 任务锁与并发写保护即使有了client_token并发场景下还是可能出问题。比如两个请求几乎同时到达都查不到已有token都去创建任务。这时候需要数据库唯一索引兜底在user_id client_token上建唯一索引第二个插入会失败捕获异常后返回第一个任务的信息。对于任务状态的并发写比如恢复推流和后台巡检同时更新同一个任务我用的是乐观锁。每次更新带上前一次读到的version更新时where version ?如果影响行数为0说明被改过重新读取再处理。这个机制在流式追加buffer时特别重要避免两个写入者互相覆盖。5. 存储选型任务状态放内存、Redis还是数据库5.1 三层存储的分工任务状态不能只放一个地方我实际项目里用了三层存储各司其职。内存存的是活跃任务的实时buffer和连接引用。正在streaming的任务buffer在内存里追加最快推流也最方便。但内存不可靠进程重启就没了所以内存只是缓存层。Redis存的是任务的热状态包括status、output_buffer、updated_at、client_token索引。Redis的读写性能足够支撑流式追加而且支持过期时间可以自动清理老任务。我用Redis的Hash结构存任务字段用String结构存client_token到task_id的映射。数据库存的是任务的最终归档包括完整的input_payload、tool_calls、最终output。数据库是持久化兜底用于审计、统计和长期查询。任务完成后从Redis把完整数据落库然后Redis里的热数据可以设置较短过期时间。三层存储的同步策略是写的时候先写Redis再异步落库读的时候先读内存再读Redis最后读数据库。streaming过程中只写内存和Redis不写数据库避免高频写打爆数据库。任务结束后统一落库一次。5.2 Redis数据结构的具体设计Redis里我用的是这样的key设计task:{task_id}用Hash存任务字段field包括status、buffer、updated_at、user_id、session_id。task:{task_id}:tools用List存工具调用记录每条是一个JSON字符串。idem:{user_id}:{client_token}用String存task_id设置24小时过期。user:{user_id}:active用Sorted Set存用户活跃任务score是updated_at用于快速查最近任务。buffer字段的追加用HINCRBY配合HSET不行因为buffer是文本。我的做法是用APPEND命令追加到单独的String keytask:{task_id}:buffer然后用STRLEN获取当前长度作为偏移量。这样追加是O(1)的获取长度也是O(1)非常适合流式场景。注意Redis的APPEND命令在集群模式下要求key在同一个slot所以task_id的生成要保证同一任务的buffer key和hash key落在同一slot。我用的是hash tag把task_id用{}包起来比如task:{abc123}:buffer和task:{abc123}这样能保证同slot。5.3 过期清理与归档策略任务数据不能无限堆积。我的清理策略分三档活跃任务running/streaming不设过期靠心跳检测处理僵死。已完成任务completed/failed/cancelledRedis里设24小时过期数据库里永久保留但做冷热分离超过7天的转到冷存储。幂等token设24小时过期和任务热数据同步。归档时要注意buffer的完整性。落库前要确认Redis里的buffer已经包含了全部输出不能落一个半截的buffer。我的做法是任务状态转为completed后先冻结buffer不再允许追加然后一次性读取完整buffer落库落库成功后再更新数据库状态。6. 实测中遇到的坑与排查过程6.1 刷新后恢复出重复内容这个坑我印象最深。现象是用户刷新后恢复出来的内容里有一段重复了比如“今天天气”变成了“今天天气今天天气”。排查过程是这样的先看前端发现前端记录的bufferLength是字符数而服务端APPEND是按字节追加的。中文一个字符占3个字节所以前端说“我收到了10个字符”服务端理解成“我收到了10个字节”从第10字节开始推就把前面已经推过的部分又推了一遍。修复方案是前后端统一用字节偏移。前端每次收到数据后用new TextEncoder().encode(chunk).length计算字节数累加。服务端用STRLEN获取字节长度。两边对齐后问题消失。这个坑的教训是涉及流式偏移量的地方一定要明确单位是字符还是字节并且前后端写死在文档里。后来我在接口文档里专门加了一行“所有偏移量均为UTF-8字节偏移”。6.2 任务卡在streaming状态不结束另一个坑是任务永远显示“正在生成中”。排查发现是模型调用超时后服务端抛了异常但异常处理逻辑只更新了内存状态没更新Redis状态。结果内存里任务已经failed了Redis里还是streaming前端恢复时读到Redis的streaming就一直等。修复方案是所有状态变更必须走统一的updateTaskStatus函数这个函数负责同时更新内存、Redis和数据库并且加日志。禁止任何地方直接改状态字段。这个约束加上后状态不一致的问题再没出现过。6.3 多标签页恢复时的推流冲突用户开了两个标签页都恢复了同一个streaming任务两个页面同时向服务端请求续流。服务端把同一段buffer推给了两个连接两个页面都渲染用户看到两份内容。解决方案是服务端对同一task_id的推流连接做互斥。用Redis的SETNX加一个task:{task_id}:stream_lock谁拿到锁谁推流另一个连接返回“任务正在其他页面恢复中”的提示。锁设置较短的过期时间比如10秒推流过程中定期续期。如果持有锁的页面关闭了锁过期后另一个页面可以重新获取。这个方案有个取舍同一时间只有一个页面能看到实时流。如果产品要求多页面同步那就不能用互斥而要用发布订阅服务端把流推给一个频道所有订阅的页面都收到。但这样实现复杂度更高我建议先做互斥版本满足大多数场景。6.4 刷新恢复时的权限校验遗漏早期版本恢复接口没有校验user_id只要知道task_id就能拉到别人的任务内容。这是个严重的安全漏洞。修复方案是恢复接口必须带用户身份服务端校验task.user_id current_user_id不匹配返回403。同时task_id的生成要用足够随机的字符串不能用自增ID避免被遍历。我用的是UUID v4加时间戳前缀既保证唯一又保证不可预测。7. 几个能直接抄的工程实践7.1 恢复流程的时序要点把整个恢复流程的时序理一遍方便你对照实现页面加载执行probeRecovery读取task_id。调probe接口拿到status和bufferLength。如果status是streaming建立SSE连接带上task_id和bufferLength。服务端校验权限和状态从bufferLength开始推流。前端收到chunk后追加渲染更新bufferLength。收到结束标记后调详情接口拉全量数据更新UI为completed。如果status是completed直接调详情接口渲染。如果status是failed展示错误和重试按钮。这个时序里第3步的SSE连接要设置重连策略。浏览器原生EventSource会自动重连但重连时带的Last-Event-ID是浏览器自己维护的可能和服务端的buffer偏移不一致。我的做法是禁用自动重连自己控制重连逻辑每次重连都重新走probe流程获取最新bufferLength。7.2 关键接口的字段约定恢复相关的接口字段命名要统一避免前后端理解偏差。我用的约定是task_id任务唯一标识。status任务状态枚举。buffer_length已生成内容的UTF-8字节长度。buffer已生成内容文本详情接口返回。client_token幂等令牌。updated_at最后更新时间戳毫秒。提交任务的接口请求体里必须带client_token响应体里必须返回task_id和status。恢复接口的响应体里必须返回buffer_length让前端知道从哪继续。7.3 监控指标不能少上线后要盯几个指标恢复成功率probe后成功建立续流的比例、重复提交拦截率client_token命中的次数、任务平均恢复耗时、streaming状态任务数。这几个指标能帮你快速发现恢复链路的异常。我遇到过恢复成功率突然下降查监控发现是Redis的buffer key过期时间设太短任务还没结束buffer就过期了恢复时读不到内容。把过期时间从任务创建时设的1小时改成动态续期每次追加buffer时刷新过期时间问题解决。7.4 降级方案要有如果Redis挂了怎么办我的降级方案是probe接口直接返回“恢复不可用”前端展示“上次任务可能已丢失请重新发起”。同时提交接口降级为直接创建任务不走幂等校验因为幂等依赖Redis。降级期间会有重复提交风险但至少保证核心功能可用。数据库作为最终兜底即使Redis全挂已完成的任务还是能从数据库读到。所以任务完成后必须尽快落库不能只留在Redis里。我设的是任务完成后1秒内异步落库落库失败重试3次3次都失败告警人工介入。这套方案我在两个项目里落地过一个是在线AI对话产品一个是内部智能体平台。前者日活几万后者任务量每天几千。实测下来刷新恢复的成功率能到99%以上重复提交拦截率100%。最大的收益是用户不再因为误刷新而丢失任务客服相关的投诉下降了七成多。如果你正在做类似的功能建议先把任务模型和幂等控制做扎实再去做流式续传的细节顺序反了会返工。