做AI前端这一年多,我发现自己面试别人或者被面试时,聊来聊去总绕不开三个词:SSE流式输出、打字机渲染、断点续传。这三个点放在一起,就是一套完整的AI对话前端方案。最早我接大模型接口时也犯过傻,拿着普通的axios去等一个二三十秒才返回的JSON,用户盯着空白页面干等,那种体验简直灾难。后来被逼着把SSE、流式解析、断线恢复整个链路啃了一遍,才算真正理解为什么AI产品的前端架构和传统后台管理系统差这么多。
这篇文章不打算写教科书式的东西,我直接按自己在生产环境里跑通的方案来讲:为什么选SSE而不是WebSocket,fetch和EventSource到底怎么选,打字机渲染遇到的编码、性能、Markdown边界问题,以及最容易被忽略的断点续传——这个功能才是流式交互真正成熟的标志。不管你是刚接触AI前端的中级开发,还是准备面试想系统梳理这块知识,应该都能从这里拿走一些能立刻用的东西。
1. 为什么AI对话必须走SSE:从“全有或全无”到“边说边听”
1.1 传统请求模式在AI场景下的致命体验
先回忆一下传统前后端交互。浏览器发一个HTTP请求,服务端处理完后一次性把完整响应丢回来,前端拿到JSON再渲染。这个模型在普通业务系统里完全够用,但放到大模型对话场景里就绷不住了。
原因很简单:大模型生成一段300字的回答,以当前主流模型的推理速度,可能要花10到30秒甚至更久。如果走传统请求,这二三十秒里用户面对的就是一个安静的转圈图标。你无法告诉用户“已经开始写了、已经写了20%”,因为数据确实还没到达前端。用户的第一反应就是刷新页面、重新提问,甚至直接认为产品坏了。
这就引出一个核心问题:模型是逐字逐词生成的,那为什么不能让前端也逐字逐词地收到内容?答案是完全可以,这就是SSE做的事情。
1.2 SSE本质上是什么
SSE全称Server-Sent Events,服务端发送事件。它最朴素的理解方式就是:普通的HTTP请求是“问一句、等半天、答一整段”,SSE是“问一句、服务器答应,然后对着同一个连接连续说话”。
它底层就是一个HTTP长连接。服务端在响应头里声明Content-Type: text/event-stream,然后不再关闭连接,而是按固定的文本格式持续往外写数据。每一段事件内容大致长这样:
id: 42 data: 你好 data: ,我是AI前端拿到这个流,逐行解析出data:后面的内容,就实现了“边说边听”的效果。关键词就藏在这里:流式输出。
1.3 为什么不用WebSocket
聊SSE几乎一定会被问:“那WebSocket不是更强大吗,还能双向通信,为什么不用它?”
我的答案很简单:AI对话这个场景,数据流动方向本质上是单向的——用户一次性把问题发出去,服务端持续把回答流回来。用户在前端点的按钮、发的消息,用一个普通POST就完成了,不需要服务端主动向客户端推送其他东西。你不需要一个双向的、全双工的通道来做一个单向的“听写”功能。
WebSocket是强,但强带来的代价是复杂:握手协议、帧格式、心跳保活、断线重连都要自己实现。而SSE是基于普通HTTP的,天生就继承了HTTP的很多能力:
- 服务端可以主动断开,客户端靠
Last-Event-ID自动重连 - 走的是标准HTTPS,不用额外开端口
- 中间经过Nginx、网关时,配置比WebSocket的升级协议更成熟
我做过一个对比表,方便你判断后续场景该用谁:
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端 -> 客户端单向 | 双向 |
| 协议复杂度 | 低,普通HTTP | 高,需要握手和帧协议 |
| 自动重连 | 协议内置 | 需自行实现 |
| 二进制数据 | 不支持 | 支持 |
| 浏览器兼容性 | 现代浏览器均可 | 同左 |
| 服务端实现成本 | 低 | 中 |
结论就是:在AI对话、日志流、股票行情这类“服务端推送为主”的场景里,SSE是性价比最高的方案。WebSocket更适合聊天室、实时协作编辑这种真正需要高频双向互动的场景。
2. 打通HTTP流:EventSource和fetch的取舍,一个都别丢
2.1 EventSource看起来很美,但坑不少
前端天然支持一个API叫EventSource,专门用来接收SSE,用法极其简单:
const es = new EventSource('/api/stream'); es.onmessage = (event) => { console.log(event.data); };刚接触SSE的人很容易直接走这条路,因为它连断线重连都帮你做了。但一旦进入真实项目,EventSource会暴露出几个让人难受的限制:
- 只能发GET请求,而绝大多数大模型接口用的都是POST,因为需要携带很长的消息列表和参数,GET根本塞不下。
- 无法自定义请求头,大模型API往往需要
Authorization鉴权token,EventSource给不了。 - 出错处理非常粗糙,连接断掉后它只会默默重连,你很难区分是正常结束还是异常中断。
- 事件格式被协议定死了,虽然支持自定义事件名,但灵活性远不如直接解析原始流。
所以在生产环境,我几乎不用EventSource,而是直接用fetch+ReadableStream自己解析流。这样POST、鉴权头、中断控制全都握在自己手里。
2.2 先用curl验证服务端有没有问题
写前端代码之前,我强烈建议先用curl把服务端验证一遍。这一步能帮你省掉大量“前端调不通到底是哪里问题”的排查时间。
curl -N -X POST https://api.example.com/v1/chat/completions \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-xxxx' \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"你好"}],"stream":true}'-N参数的意思是禁用缓冲,让curl收到一个字节就输出一个字节。如果服务端真的在走SSE,你会看到一行一行以data:开头的内容慢慢打出来,而不是等所有内容都完事才一次性输出。这一步通过了,说明问题出在浏览器侧或中间网络;如果连curl都等半天不出内容,那问题基本都在服务端配置或模型响应速度上。
2.3 一个最小可跑的前端流式解析实现
下面是我常用的一个基础版实现,能满足大部分AI对话场景的需求:
async function chatStream({ messages, token, onChunk, signal }) { const resp = await fetch('/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages, stream: true }), signal, }); if (!resp.ok) { throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); } const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; // 重要:这里的stream:true是为了处理多字节字符被拆到两个chunk的情况 buffer += decoder.decode(value, { stream: true }); // SSE协议按换行分割事件 const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (data === '[DONE]') return; try { const json = JSON.parse(data); const delta = json.choices?.[0]?.delta?.content; if (delta) onChunk(delta); } catch { // 留空,容忍不完整的JSON } } } }这段代码里有个细节值得注意:buffer += decoder.decode(value, { stream: true })。
这里解决的是网络分包问题。数据在网络传输时,一个UTF-8中文字符的3个字节可能被拆到两个TCP包里,如果你直接对每次收到的value做字符串拼接,很可能会在中间位置出现一个残缺的乱码字符。TextDecoder的stream: true模式会在内部保存未完成的字节序列,等下一个chunk到了再一起解析,从根上解决这个问题。
3. 打字机渲染:把Token流变成流畅的视觉体验
3.1 增量追加,而不是整块替换
流式数据到了前端,接下来就要解决“怎么显示”的问题。最容易踩的坑是:每次收到一个chunk就重新执行content.innerHTML = allContent。
在大模型场景下,一个回答往往有几万字,内容越长,整块替换的代价越大。更糟糕的是,整块替换会破坏用户已选中的文本、重置滚动位置,如果内容里有图片还会导致闪烁。正确做法是维护一份完整的文本缓存,同时只把新增的部分追加到DOM里。
我自己习惯用一个简单的模式:
// 完整内容缓存,用于后续拼接和逻辑判断 let fullContent = ''; // 初次渲染创建一个空的容器 const container = document.getElementById('answer'); // 每次收到新chunk,只追加新文本 function onChunk(delta) { fullContent += delta; container.insertAdjacentText('beforeend', delta); }这里用insertAdjacentText而不是innerHTML,一方面是性能更好,另一方面是安全——杜绝了模型输出内容被当作HTML解析而引发的XSS问题。很多AI答非所问输出一段<img src=x onerror=alert(1)>之类内容的场景,只有用文本方式插入才安全。
3.2 用requestAnimationFrame合并高频更新
但“每次都追加”也不是最优解。大模型输出的速度并不慢,特别是在流式传输时,浏览器可能在一秒内收到几十次增量事件。如果每次都立刻操作DOM,主线程会被频繁打断,页面会出现明显卡顿。
解决思路是合并渲染:把DOM更新推迟到浏览器下一次重绘之前执行,同一帧内的多次增量合并成一次操作。
let pendingContent = ''; let rafId = null; function scheduleRender() { if (rafId) return; rafId = requestAnimationFrame(() => { container.insertAdjacentText('beforeend', pendingContent); pendingContent = ''; rafId = null; autoScrollIfNeeded(); }); } function onChunk(delta) { pendingContent += delta; scheduleRender(); }实测下来,这种方式在长文本场景下能明显降低主线程的占用率。而且这个方案还有一个附加好处:自动控制滚动位置的过程也集中到了一次,不会因为频繁滚动导致用户无法阅读。
3.3 Markdown流式解析:代码块和表格断裂怎么处理
AI对话的回复基本上都是Markdown格式,标题、列表、代码块、表格都有。流式场景下最头疼的就是:内容还在接收中时,Markdown文档可能处于一个“半闭合”状态。比如代码块的三反引号已经输出了开头那个,但还没输出结束的,如果你强行对整个半截内容做Markdown解析,渲染结果会非常诡异。
我的经验是分两步走:
- 流式过程中只做轻度“安全解析”:对回车、空格、代码块起始标记做简单处理,保证阅读体验基本正常。重点是把代码块识别出来并以等宽字体预览,不用追求完整高亮。
- 结束后做完整解析:收到结束标记时,用完整的Markdown渲染流程(比如
marked或markdown-it)重新渲染一遍,得到最终的美化效果。
另一个需要特别注意的问题是表格。Markdown表格在还没收尾的时候经常处于行数不全、列数不齐的状态,渲染出来会很难看。我在做的时候发现,流式过程中临时渲染的表格一旦缺行少列,用户看到的就是一个歪歪扭扭的界面。所以对于表格,我反而建议流式过程中先不渲染成表格,而是显示为原始文本,等完整了再处理。
给一个通用的处理策略表格:
| 内容类型 | 流式中的表现 | 流式结束后的处理 |
|---|---|---|
| 普通文本 | 直接追加 | 无变化 |
| 一级/二级标题 | 正常显示 | 完整渲染 |
| 代码块 | 未闭合时只显示等宽文本 | 语法高亮 |
| 列表 | 正常显示 | 完整渲染 |
| 表格 | 保持原始文本,不提前渲染 | 完整渲染为表格 |
| 图片链接 | 可以展示,但注意防盗链 | 最终重查 |
这个“流式中保守、结束后完整”的策略,是我试过很多种方案后觉得体验最好的。
4. 断点续传:AI说到一半断了,不能每次都从头再来
4.1 断连场景拆解:谁断了、为什么断
SSE连接虽然很稳,但在真实网络环境下,断连几乎是必然事件。我把断连原因大致分成几类:
| 断连类型 | 典型原因 | 特征 |
|---|---|---|
| 用户主动取消 | 用户点击停止按钮 | 前端主动调用abort |
| 网络抖动 | 移动网络切换、Wi-Fi信号波动 | 连接突然关闭 |
| 网关超时 | 代理层长时间没收到数据而断开 | 报错信息里常有idle timeout |
| 服务端异常 | 模型报错、服务器重启 | HTTP状态码异常或连接被重置 |
前端最容易感知的是第一种和第三种。第一种是自己人干的,很好处理;第三种则是经典的“idle timeout waiting for SSE”报错。后面我会专门用一个章节展开讲,这里先把断点续传的整体架构理清楚。
4.2 续传协议:用lastEventId把断点衔接起来
断点续传的核心思路是:断线时记录“我已经收到了多少内容”,重连时把断点位置告诉服务端,服务端从这个位置继续生成,而不是从头开始。
SSE协议本身提供了id字段和Last-Event-ID请求头,专门用来做这件事。服务端在发送每段数据时,可以带上事件id:
id: 167 data: 继续 id: 168 data: 输出浏览器收到这些id后,如果连接断开并自动重连,会在下一次请求的请求头里带上Last-Event-ID: 168,服务端看到这个值后,就能从事件168之后继续推送。
但要注意,前面说过生产环境里我们一般不用EventSource,而是用fetch自己实现。这意味着浏览器不会自动帮你维护Last-Event-ID,你需要自己处理记录和回传。
我的做法是:前端每次收到数据时,不只是往界面里追加文本,还会把已接收到的完整内容(或者最新的事件id)记录到一个存储里。断线后重连时,把这段记录传到服务端。
4.3 前端状态编排:中断不等于失败
断点续传体验做得好不好,关键看前端如何处理“中断”这个状态。早期我犯过一个错误:只要连接出错,就把整个对话状态清空重来,用户在屏幕上看到的就是“正在重新生成”。
后来我把中断看作一个独立的状态,而不是失败。基于这个思路,我设计了下面这个状态机:
| 状态 | 含义 | 进入条件 | 离开方式 |
|---|---|---|---|
| idle | 空闲,等待用户输入 | 页面加载 | 发送消息 -> connecting |
| connecting | 已发出请求,等待响应 | 用户提交 | 首包到达 -> streaming |
| streaming | 正在接收流式内容 | 收到首个增量 | 收到结束标记 -> done |
| interrupted | 连接中断,但已收到部分内容 | 断线/用户暂停 | 点击“继续生成” -> connecting |
| error | 不可恢复的错误 | 鉴权失败/参数错误 | 用户重新发送 |
| done | 输出结束 | 收到完整结束事件 | 清空上下文 -> idle |
引入interrupted这个状态后,用户感知到的交互就变成了:AI刚说到一半,界面提示“连接已中断”,旁边一个“继续生成”的按钮。点击后,前端带着已接收到的内容ID重新请求,服务端跳过已经生成的部分,继续输出剩下的。
4.4 一个极简的断点续传实现
为了让你更直观地理解,我写一个简化的续传实现。核心是用已接收的文本长度作为断点标识:
let receivedLength = 0; let fullContent = ''; async function startStream() { try { await readChunks({ // 告诉服务端已经收到多少内容了 startFrom: receivedLength, onChunk: (delta) => { fullContent += delta; receivedLength += delta.length; render(); }, }); } catch (err) { if (isInterruptedError(err)) { // 不要清空内容,只切换状态到中断 setStatus('interrupted'); } else { setStatus('error'); } } } async function continueStream() { // 把已断点位置带到新请求 setStatus('connecting'); await startStream(); }需要注意的是,用“文本长度”当断点其实并不精确,尤其经过网络分包和编码处理后,长度和内容并不是完全对应的。更可靠的方式是让服务端生成时给每个事件分配一个递增id,前端把最后收到的id传回去。这个方案更贴近SSE协议设计的本意,也更能应对内容变化的情况。
我在实际项目中采用的结构是:前端存对话历史 + 最后事件id,服务端根据对话上下文和事件id计算恢复点。恢复点的计算逻辑可以放在服务端的会话缓存里,不占用模型的重复生成成本。
5. 生产环境踩坑实录:SSE那些防不胜防的怪问题
5.1 “idle timeout waiting for SSE”:网关层层切断
在真实项目里,SSE连接会经过很多中间层,最常见的坑就是代理层或网关层把长连接断掉。经典的报错信息是这样的:
stream disconnected before completion: idle timeout waiting for sse这句话的翻译是:在等待SSE事件的过程中,连接的空闲时间超过了中间层的超时阈值,被强制关闭。
什么情况下会出现空闲超时?跟很多人直觉相反,SSE连接空闲不是指用户什么都不干,而是指在一段时间内没有数据从服务端发到客户端。比如模型在思考一个复杂问题时,某些模型(特别是非流式模式的过渡阶段)可能间隔十几秒才生成第一批数据。如果一个中间层设置了30秒空闲超时,这十几秒可能没事,但一旦模型思考超过30秒,连接就被切了。
排查这个问题的链路,我建议按这个顺序走:
- 先用curl直连服务端,看是否长时间无输出。如果curl能正常等到数据,说明服务端没问题。
- 检查最外层的Nginx配置,重点看两个参数:
proxy_read_timeout和proxy_buffering。 - 检查是否有更高层的云负载均衡、CDN网关,它们通常有独立的空闲超时配置。
Nginx侧常见的解决方案是:
location /v1/chat/completions { proxy_pass http://upstream_ai_service; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_buffering off这一步尤其关键。如果开启缓冲,Nginx会把整个响应攒起来再一起返回,SSE的流式效果就完全失效了,前端会表现为“等了很久最后一次性弹出一大段”。
除此之外,如果中间层无论如何都有关闭连接的限制,还有一个保底方案:让服务端每隔15到20秒发一个SSE注释行作为“心跳”。注释行在SSE协议里以:开头,前端解析时会忽略掉,但它能让中间层知道“连接还活着”,避免误判为空闲。
5.2 中文乱码和残缺字符:TextDecoder的stream参数
前面提到的TextDecoder流式解码问题,在生产环境踩过一次就记住了。某次联调时,我发现对话回答偶尔在“的”“了”这类常用字的位置出现一个替换符号“�”。排查半天,确实不是服务端的问题,因为curl抓到的响应是完整的。
原因就在浏览器侧的解码方式。如果直接用new TextDecoder().decode(value),当网络分包把一个中文字符的多个字节拆到两个chunk时,前一个chunk末尾的字节不完整,解码器不知道这是一个多字节字符的一部分,就输出一个替换符。而后一个chunk开头的剩余字节又会被当成一个新的独立字符,造成双重乱码。
解决办法就是加上{ stream: true },让解码器内部维护一个字节缓冲区,不完整的字节序列先留着,等后续字节到了再组合起来。这是所有用fetch做流式解析的前端必须写的一行,没有例外。
5.3 浏览器同一域名的并发连接数限制
SSE是长连接,占用的是浏览器对同一域名的并发连接额度。Chrome对HTTP/1.1协议下同一域名的连接数限制是6个。也就是说,如果用户同时在多个标签页打开AI助手,第六个标签页的SSE连接可能一直建立不起来,或者排队等待前面的连接释放。
处理办法有三个方向:
- 给SSE接口挂一个独立的域名或子域,比如
stream.example.com,与主站页面分离,避免页面其他资源占用连接名额。 - 升级到HTTP/2,HTTP/2对同域名下长连接的数量限制宽松很多,多个流可以复用同一个TCP连接。
- 如果产品形态是多个组件同时需要SSE,建议在服务端做事件合并,前端只建立一条SSE连接,服务端按频道分发不同事件,而不是每个组件各拉一条。
第三种方案对微前端架构尤其重要。像qiankun这类微前端框架下,多个子应用可能同时挂载,如果每个子应用都各自建立SSE连接,连接数很快就爆了。我当时就把SSE的建立和分发统一放到主应用的全局模块里,子应用通过事件总线订阅自己关心的频道,这样整个平台只需要一条SSE连接。
5.4 移动端切后台、锁屏,连接状态必须感知
手机浏览器切到后台后,系统会很快挂起页面进程,SSE连接在这段时间里收不到数据。回到前台时,TCP连接往往已经因为超时被服务端或中间层回收了,但前端页面的状态还停留在streaming。
这个场景不处理好,就会表现为:用户锁屏再解锁,页面一直转圈,再也不出字了。
我的应对策略分两步:
- 监听
visibilitychange事件,页面从可见切到隐藏时,记录当前状态并暂停自动滚动等高频操作;切回可见时,检查连接是否还活着,如果状态显示仍在loading但连接已经不可用,就主动触发断线重连。 - 如果请求是POST且带进度信息,可以把已接收内容保存到
sessionStorage或IndexedDB,切回前台时基于断点续传逻辑恢复。
这个思路和前面断点续传是同一个体系,只是触发时机从“断报错”变成了“页面从后台回到前台”。
6. 封装一个可复用的AI对话组件:状态机、心跳、对外API
6.1 把状态机固化到组件里
前面提了状态机,这里我就把完整的设计落地成一个可复用的类。做这件事最大的好处是,业务页面不再需要关心底层的连接细节,只要订阅状态变化就行。
type ChatStatus = 'idle' | 'connecting' | 'streaming' | 'interrupted' | 'error' | 'done'; interface ChatClientOptions { url: string; token: string; onStatusChange: (status: ChatStatus) => void; onChunk: (delta: string) => void; onError?: (err: Error) => void; } class ChatStreamClient { private controller: AbortController | null = null; private receivedId = ''; private fullContent = ''; status: ChatStatus = 'idle'; constructor(private options: ChatStreamClientOptions) {} async send(messages: Message[], extra: Record<string, unknown> = {}) { // 发送请求前取消上一个未完成的连接 this.abort(); this.controller = new AbortController(); this.fullContent = ''; this.receivedId = ''; this.setStatus('connecting'); try { await this.fetchStream(messages, extra, this.controller.signal); this.setStatus('done'); } catch (err) { if (isAbortError(err)) { // 主动取消不认为是错误 } else { this.setStatus('interrupted'); this.options.onError?.(err); } } } stop() { this.controller?.abort(); } async resume(messages: Message[]) { // 从lastEventId继续 this.controller = new AbortController(); this.setStatus('connecting'); try { await this.fetchStream(messages, this.controller.signal); this.setStatus('done'); } catch (err) { if (!isAbortError(err)) { this.setStatus('interrupted'); } } } private setStatus(status: ChatStatus) { this.status = status; this.options.onStatusChange(status); } }这个类对外暴露的核心就三个方法:send、stop、resume。UI层只管调用,状态变化通过回调推给页面,页面根据不同的status决定展示加载动画、流式内容、还是“继续生成”按钮。
6.2 心跳和超时:不让假死状态骗过用户
连接建立后,除了等待服务端数据,前端还要自己养一个定时器做兜底。我设置的逻辑是:
- 每15秒检测一次,如果距上次收到数据超过了60秒,就认为连接已“假死”。
- 此时主动调用
controller.abort(),触发断线逻辑,进入interrupted状态。 - 用户点击“继续生成”后,才重新发起请求。
这个心跳机制和前面说的Nginx idle timeout虽然出发点不同,但作用是一样的——任何长连接系统都需要一个“谁都无法保证永远在线”的兜底逻辑。做AI对话组件时这套逻辑是标配,不做的话线上早晚会有人截图给你看“转圈圈转了一晚上”的bug。
6.3 面试官视角:AI前端SSE这道题怎么答才出彩
前阵子帮团队面前端,专门用SSE这道题筛人。初级候选人普遍能说出“SSE是服务端推送”、“用EventSource接”。但能让我眼前一亮的回答,基本都覆盖了以下几条:
- 知道EventSource的限制(GET、不能带header),所以用fetch + ReadableStream做实现。
- 能解释
TextDecoder的stream: true解决了多字节字符乱码。 - 知道Nginx需要关
proxy_buffering,不然流式会失效。 - 考虑过断线恢复,对断点的设计有自己的思考而不是照搬文档。
- 能说清SSE和WebSocket的选型边界,而不是“WebSocket更高级所以用WebSocket”。
如果你准备面试,建议按这个清单自查一遍,能用自己的话讲清楚每一条,这道题基本就稳了。
6.4 扩展思路:从“断点续传”到“会话恢复”
最后再分享一个我最近在做的扩展:把断点续传的思路进一步放大,做完整的会话恢复。
断点续传只解决“一次回复中断了能接上”的问题,但用户真正想要的可能是:我关掉浏览器,第二天回来还能看到昨天的对话记录,甚至能继续提问。
这个需求可以拆成两部分,历史消息本地持久化 + 服务端按对话ID恢复上下文。前端把每条消息(包括用户消息和AI消息)持久化到IndexedDB,下次打开时先渲染历史,再基于历史上下文发起新的请求。服务端则通过对话ID在会话缓存中保留模型的上下文状态,前端发新请求时带上对话ID,保证模型的“记忆”没有断。
这个方案和断点续传在技术上是同一套体系,区别只是把“断点”的保存从内存换到了持久化存储。从工程演进角度看,先把断点续传做好,再往会话恢复扩展,路径会非常顺。
我自己的体会是,所谓“AI前端落地”,扎实的技术实现当然重要,但更关键的是你要始终盯着用户感受到的交互细节:首批字出现得快不快、中断后能不能无缝接上、长时间等待时有没有反馈。把这些细节做到位,比堆一堆炫技的技术名词有意义得多。