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_id | string | 全局唯一任务ID,创建时生成,贯穿整个生命周期 |
| session_id | string | 会话ID,用于把多个任务归到同一对话下 |
| user_id | string | 用户标识,用于权限校验和跨设备恢复 |
| status | enum | pending / running / streaming / completed / failed / cancelled |
| input_payload | json | 用户原始输入,包括文本、附件引用、参数配置 |
| output_buffer | text | 已生成的输出内容,流式过程中持续追加 |
| tool_calls | json | 工具调用记录,含调用参数、返回结果、时间戳 |
| created_at | timestamp | 任务创建时间 |
| updated_at | timestamp | 最后更新时间,用于判断任务是否僵死 |
| client_token | string | 客户端幂等令牌,防止重复提交 |
| version | int | 乐观锁版本号,防止并发写覆盖 |
这里有几个字段的设计意图需要展开说。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字节偏移,前后端都按字节计算,问题才解决。
另一个坑是流式协议的选择。SSE(Server-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/cancelled):Redis里设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%。最大的收益是用户不再因为误刷新而丢失任务,客服相关的投诉下降了七成多。如果你正在做类似的功能,建议先把任务模型和幂等控制做扎实,再去做流式续传的细节,顺序反了会返工。