
现在但凡做一个带AI对话功能的产品“流式输出”基本就是标配了。用户已经习惯ChatGPT那种字符一个个蹦出来的效果你要是做个转圈等待好几秒再一次性返回体验直接掉一个档次。我最近正好把一个内部 AI 助手从前端到后端的完整链路重构了一遍核心就三件事SSE 流式输出、断点续传、打字机渲染。这篇文章把我踩过的坑、最终落地下来的方案、以及可以直接“抄作业”的代码片段都整理出来给正在做 AI 前端的同学一个参考。先说清楚一个前提这篇文章面向的是真正要把 AI 对话能力嵌进自己业务系统的前端工程师你可能已经接过大模型 API但没有系统处理过流式场景。也可能你刚准备做正纠结用 EventSource 还是 WebSocket、中断了怎么办、markdown 怎么边接收边渲染。下面这些内容都是我在实际项目里验证过的做法不是教科书式理论很多细节是文档里不会写的。1. 项目概述与核心技术拆解1.1 一次AI对话请求背后的完整链路用户在前端输入问题点击发送后端将请求转发到大模型大模型开始逐个 token 生成服务端通过 HTTP 流持续把数据推给浏览器前端一边接收一边把已经到达的内容渲染成文本、markdown 或代码块。整个过程看起来是“字在蹦”实际上背后是三个动作同时在跑传输层持续接收、数据层持续累积、渲染层持续增量更新。这三个动作正好对应标题里的三个能力。SSE 负责“传输”断点续传负责“传输断了之后怎么补”打字机渲染负责“怎么把收到的内容平滑地展示给用户”。很多人只关注其中一个点比如只把 fetch 改成流式接收就以为完成了结果网络一抖动对话就断或者页面渲染大量文本时卡成幻灯片。我的经验是这三块必须一起设计不能各自为政。这个方案适合谁适合做 AI 对话、AI 写作、AI 客服、智能导诊这类“单向请求、持续返回文本”的产品。如果你的业务是实时音视频、多人协同编辑、聊天室这类需要双工通信的场景那该用 WebSocket别硬套 SSE。后面我会专门对比这个问题。1.2 为什么AI流式场景首选SSE而不是WebSocket这个问题我在项目定技术方案时被团队问过很多次。大模型推理本质上是“客户端发起一次请求服务端持续返回流式结果”这是一个明显的单向通信场景SSE 天然匹配。WebSocket 支持双向通信但换来的是更复杂的握手、心跳、消息协议、断线重连逻辑而这些复杂度在 AI 对话场景里大部分是用不到的。SSE 基于 HTTP自带一些很有用的能力比如 EventSource API 自动重连、Last-Event-ID 续传、标准的事件帧格式。它走普通 HTTP/HTTPS 端口不容易被公司网关或防火墙拦截接入成本低。WebSocket 需要单独的服务端支持升级协议是 ws:// 或 wss://如果你们公司有严格的网络策略可能还得专门申请白名单。有同学会反驳EventSource 不支持 POST不支持自定义 Header而有的大模型网关必须带 Token这不就废了对所以我的方案里没有直接使用 EventSource而是用 fetch 配合 ReadableStream 自己实现了一套 SSE 客户端既保留了 SSE 的帧格式又能自由设置 Header、发送 POST还能做更细粒度的中断控制。这个方案本质上还是解析 SSE 流不是改用 WebSocket。后面第 2 章我会把代码拆开讲。2. SSE流式输出的前端实现2.1 EventSource的局限性EventSource 是浏览器内置的 SSE 客户端使用非常简单const es new EventSource(/api/chat/stream); es.onmessage (event) { console.log(event.data); };它自动处理连接、断线重连简直开箱即用。但我在实际项目里很快就发现三个问题。第一个问题是只能用 GET 请求。现在的大模型接口很多要求 POST尤其是你想把用户问题和历史消息都放到 body 里时EventSource 完全无能为力。有一些网关支持 GET 加 query 参数但 URL 长度限制以及敏感信息暴露都是隐患。第二个问题是没法自定义 Header。AI 产品的接口通常要带Authorization: Bearer xxx或者X-Api-KeyEventSource 的 Header 是浏览器固定生成的你没法加。这导致很多团队只能把密钥放到服务端再由服务端转发给前端虽然这确实是标准做法但如果你希望前端直连某些大模型平台就非常受限。第三个问题是中断控制粒度太粗。EventSource 只提供close()遇到超时、HTTP 404、500 这类事件它处理得并不优雅。你想根据业务状态决定“这次报错是否重试”还得手动管理 EventSource 实例反而更麻烦。所以我的结论是EventSource 适合原型验证或内部工具不适合作为生产环境里 AI 对话的主传输方案。于是我用 fetch ReadableStream 自己实现了一个“能发 POST、能带 Header、能自己控制帧解析”的 SSE 客户端。2.2 用fetchReadableStream手写SSE客户端核心思路很简单fetch 发起请求后response.body是一个ReadableStream我们用getReader()逐块读取二进制数据再用TextDecoder转成字符串最后按 SSE 的格式拆帧。SSE 的帧格式是以data:开头以空行\n\n表示一条事件结束。如果有多行data:则它们会被合并成一条事件用换行符分隔。下面是我在项目里沉淀下来的精简版代码去掉业务噪声只留核心解析逻辑async function requestSSE({ url, params, headers, onMessage, onDone, onError, signal }) { const resp await fetch(url, { method: POST, headers: { Content-Type: application/json, ...headers, }, body: JSON.stringify(params), signal, }); if (!resp.ok) { const errText await resp.text().catch(() ); throw new Error(HTTP ${resp.status}: ${errText}); } const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 事件以空行分隔这里拆出完整的 event 块 let sepIndex; while ((sepIndex buffer.indexOf(\n\n)) ! -1) { const rawEvent buffer.slice(0, sepIndex); buffer buffer.slice(sepIndex 2); handleRawEvent(rawEvent); } } // 处理最后一块没以 \n\n 结尾的残余数据 if (buffer.trim()) { handleRawEvent(buffer); } function handleRawEvent(rawEvent) { const lines rawEvent.split(\n); const dataLines []; for (const line of lines) { const trimmed line.trim(); if (trimmed.startsWith(data:)) { const payload trimmed.slice(5).trim(); if (payload ! [DONE]) { dataLines.push(payload); } } } if (dataLines.length 0) return; const eventData dataLines.join(\n); try { const json JSON.parse(eventData); onMessage?.(json); } catch (e) { onError?.(new Error(SSE data parse error)); } } }这里面有几个细节值得展开。第一TextDecoder一定要在循环里使用{ stream: true }。这是因为流式数据的边界不一定和 UTF-8 字符边界对齐一个中文字符的多个字节可能被拆到两次read()里。如果你直接用decoder.decode(value)而不带stream: true遇到被截断的多字节字符就会出现乱码。而{ stream: true }会让 decoder 把未完成的字节缓存在内部等后续字节到达后再一起输出。第二拆帧时不能只找\nSSE 标准要求事件以空行结束。有的服务端可能返回\r\n保险起见可以在拆帧前先做归一化把\r\n替换成\n或者像我代码里那样每行都trim()。否则空行判断可能失效。第三一次reader.read()返回的 chunk 可能包含多条事件也可能只包含半条事件所以必须用buffer做累积每次循环把完整的帧切出来剩下的留在 buffer 里继续等下一块。很多初学者就容易在这里写错导致数据解析不完整。2.3 数据帧格式与终止判断主流大模型接口返回的流式数据通常是 OpenAI 兼容格式每条事件是一段 JSON比如data: {id:chatcmpl-xxx,object:chat.completion.chunk,choices:[{index:0,delta:{content:你好},finish_reason:null}]}然后空行结束一条事件最终以data: [DONE]表示整个流结束。我们解析的时候要同时处理好两种终止情况读到[DONE]代表正常结束reader.read()返回done: true代表 HTTP 连接被关闭。正常流程下服务端会先发完所有data块再发[DONE]最后关闭连接。但有些网关实现不标准可能不发[DONE]直接关连接所以我们需要把两种情况都视为“结束”。在处理结束时要调用一次onDone()回调同时清理状态比如关闭 loading 态、停止打字机光标、把未消费完的 buffer 丢弃。这块我统一抽象成finish()函数function finish() { if (finished) return; finished true; onDone?.(); reader.releaseLock(); }关于finish_reason在最后一条 chunk 里可能出现finish_reason:stop这也是个有用的信号可以提示我们流已经进入收尾阶段但还是要以[DONE]或连接关闭为准。3. 断点续传方案设计3.1 理解流式场景下的“断点续传”到底续什么提到“断点续传”前端同学第一反应是 HTTP 文件下载断点续传用Range头加206状态码服务端从指定字节位置继续发送文件内容。但 AI 流式输出的场景完全不一样因为大模型回答在开始生成前并没有一个固定的“完整结果”没有 Content-Length服务端也是一边生成一边下发你没法拿一个确定长度去算断点。所以 AI 场景下我理解的“断点续传”有两层意思。第一层是传输层续传连接断了尽快重连并且尽量减少用户看到的中断时间。如果服务端支持按事件 ID 续传还可以从断掉的最后一个事件继续推而不是从头开始。第二层是业务层续传传输层恢复之后大模型上下文早就变了或者服务端根本不支持从某个 token 继续生成。这时候我们需要在“用户看到的内容”这个层面做续传也就是把已经生成的文本缓存下来然后重新发起一次请求让模型“接着没写完的地方继续写”。这两层方案不冲突实践中往往是组合使用。我在项目里采用的方式是优先做传输层自动重连如果重连几次仍然失败就降级到业务层续传。这样做的好处是网络抖动时用户几乎无感知只有持续断连才会触发业务层补偿逻辑。3.2 传输层断流重连与Last-Event-IDSSE 协议里有一个专门做续传的字段Last-Event-ID。标准用法是浏览器每次成功收到一个事件就用id:字段记录事件 ID断线重连时自动在请求头带上Last-Event-ID服务端根据这个 ID 把缺失的事件重新推送。EventSource 原生就支持这套逻辑但我们自己用 fetch 实现时需要手动把最后收到的事件 ID 存下来并在重连请求的 Header 里带上。我改造后的请求头长这样let lastEventId null; // 解析事件时保留 id function handleRawEvent(rawEvent) { for (const line of rawEvent.split(\n)) { const trimmed line.trim(); if (trimmed.startsWith(id:)) { lastEventId trimmed.slice(3).trim(); } } // ... 继续解析 data } // 重连时带上 Last-Event-ID const headers { Content-Type: application/json, ...(lastEventId ? { Last-Event-ID: lastEventId } : {}), };要让这套机制真正生效服务端必须支持“从指定事件 ID 继续推送”。这个 ID 通常是一个自增序号或者是大模型 token 序号。比如服务端每推一个 chunk就生成id: 42如果客户端带着Last-Event-ID: 42重连服务端就从第 43 个 token 继续推。但这里有个现实问题大模型 API 本身是无状态的同一个请求生成到一半你重新发起请求模型大概率已经丢失了生成状态刚从断点继续推是做不到的。除非你在后端自己做缓存和状态管理否则传输层续传只能恢复到“重新建立连接”不能恢复到“原来的生成进度”。所以我给大多数团队的方案是后端在转发大模型流时先把每个事件存一份到 Redis 或内存队列维护一个递增 id这样断线重连时后端可以从缓存里读取已经生成过的 chunk先把历史补推给前端再继续转发新生成的内容。这个方案需要后端配合但实现并不复杂而且体验最好。3.3 业务层续写缓存已生成内容让模型接着讲如果后端没有实现事件缓存或者你直接调的是第三方大模型 API那断线之后最稳妥的做法就是业务层续写前端把已经完整渲染出来的文本保存到状态里然后重新请求时把这个文本放进上下文让模型理解“你还没说完请继续”。这个思路很像让一个人帮你写文章写到一半电话断了你重新打电话说一句“刚才你写到第 500 字从那里继续写”即可。问题是大模型是否能准确定位“第 500 字”以及是否会产生重复内容。我在项目中用到的方案是这样的const session { history: [], // 历史对话消息 partialAnswer: , // 当前这条回答已经生成的内容 lastIndex: 0, // 当前内容渲染到的字符位置 isResuming: false, // 是否为续写模式 }; function resumeRequest() { const continuePrompt 请接着我之前让你写的内容继续写不要重复已经写过的内容。内容如下\n${session.partialAnswer}\n\n从这里继续; requestAI({ role: user, content: continuePrompt, // 让服务端把 partialAnswer 也作为上下文同时标记为续写模式 resumeFrom: session.partialAnswer.length, }, { onChunk: handleChunk, }); }续写请求发送之后前端接收新的 chunk此时要做一个关键处理不能直接把新内容接到 partialAnswer 末尾就完事因为模型大概率会重复一部分。我在handleChunk里做了一层“重叠检测”也就是拿新内容的开头去找 partialAnswer 的结尾部分如果有重合就切掉重复的一段。function mergeChunk(prevText, newText) { // 寻找 prevText 后缀与 newText 前缀的最长重叠 const maxOverlap Math.min(prevText.length, newText.length); for (let i maxOverlap; i 0; i--) { if (prevText.slice(-i) newText.slice(0, i)) { return prevText newText.slice(i); } } return prevText newText; }这个重叠检测在实践里非常有用因为模型并不知道断点在哪里即使你提示它“不要重复”它还是可能多写一小段。用最长重叠匹配能有效消除重复内容同时避免漏字。代价是可能误删一些确实需要重复的字符比如排比句开头的重复词但从用户体验看利大于弊。3.4 重连策略与用户无感的边界处理断线重连不能无限重试否则服务端压力大用户也一直卡在“加载中”。我在项目里采用的策略是梯度重试第一次断线马上重连第二次延迟 1 秒第三次延迟 2 秒第四次延迟 5 秒最多重试 5 次。超过 5 次走业务层续写如果续写请求也失败就给用户明确提示并保留已生成内容允许手动点击“继续生成”重试。const RETRY_DELAYS [0, 1000, 2000, 5000, 10000]; let retryCount 0; async function connectWithRetry(params) { try { await requestSSE(params); } catch (error) { if (error.name AbortError) { // 用户主动取消不做重试 return; } if (retryCount RETRY_DELAYS.length) { const delay RETRY_DELAYS[retryCount]; retryCount; setTimeout(() connectWithRetry(params), delay); } else { // 进入业务层续写 resumeRequest(); } } }这里有两个边界要注意。第一如果用户已经看到“回答不完整”的提示说明传输层重试失败此时要保留当前 partialAnswer 在页面上不要清空后重新生成否则用户会觉得内容被替换了。第二用户主动取消时要使用 AbortController 中止 fetch同时中止任何进行中的重连定时器否则会出现“用户都关闭对话了还在后台悄悄重连”的诡异现场。另外还要考虑服务端本身是否真的断连。有时候不是断网而是服务端 5 分钟内没有新 token浏览器默认超时不会触发需要前端自己判断“流是否还活着”。我通常给流式请求加一个内部看门狗每次收到数据都重置一个计时器如果 30 秒没有任何数据就主动标记为超时并触发重连。这个计时器在初始化时启动在 finally 里清除。4. 打字机渲染与性能优化4.1 高频更新下如何避免渲染卡死SSE 流式接收的数据频率相当高大模型输出快的能达到每秒几十个 token。如果每个 token 到达都直接setState并触发一次完整 Markdown 解析和 DOM 重建页面很快就会卡顿尤其是当回答内容积累到几千字后每次全量 diff 都会吃掉大量主线程时间。我的优化思路是接收层和渲染层之间加一个“缓冲队列”。收到 chunk 后不立即渲染而是 push 到一个 buffer 里然后用定时器或者requestAnimationFrame周期性地把 buffer 里的增量内容批量刷到 DOM 上。class TypewriterBuffer { constructor({ renderInterval 50, onRender }) { this.buffer ; this.timer null; this.renderInterval renderInterval; this.onRender onRender; } push(text) { this.buffer text; this.start(); } start() { if (this.timer) return; this.timer setInterval(() { if (!this.buffer) { this.stop(); return; } const chunk this.buffer; this.buffer ; this.onRender(chunk); }, this.renderInterval); } stop() { clearInterval(this.timer); this.timer null; } }renderInterval我推荐 30ms 到 50ms 之间。30ms 大概对应每秒 33 帧视觉上依然是平滑的50ms 对应每秒 20 帧字多时能大幅减少渲染压力。如果你还需要同时解析 Markdown建议把间隔调到 50ms 到 80ms给解析腾时间。有一个细节容易忽略如果 buffer 里的增量被拆成了半个 CSS 类名比如内容正好在classhljs中间被截断渲染时整段代码样式就会坏掉。所以增量渲染之前最好等一等或者从代码块边界处重新对齐。这个我在 4.2 里展开讲。4.2 增量Markdown渲染的正确姿势AI 回答大多包含 Markdown比如代码块、列表、表格。如果直接把每个增量片段丢给 Markdown 解析器会遇到一个经典问题内容不完整时Markdown 解析会产出错误的临时结构。比如这段代码块javascript const a 1;在流式过程中你收到的顺序可能是javascript const a 1如果走到第一步就把javascript交给解析器那它是一个未闭合的代码块解析器会把它当作普通段落直到后面的内容补齐。如果每一步都在同一个 DOM 节点上重新解析全文结果倒是最终正确的但会频繁重建 DOM破坏输入框光标和滚动位置。我的做法是“分段渲染 保护未闭合块”把已接收的文本缓存到fullText。每次渲染时判断fullText当前是否处于代码块内部。如果在代码块内部就把代码块开始之前的文本正常走 Markdown 渲染代码块后面未闭合的部分用textContent设置到pre里不做 Markdown 解析。表格同理未闭合的表格行可以先当普通文本展示等闭合后再重新渲染为表格。代码示意function splitIncompleteBlock(rawText) { const lines rawText.split(\n); let inCodeBlock false; let splitIndex -1; for (let i 0; i lines.length; i) { if (lines[i].trim().startsWith()) { inCodeBlock !inCodeBlock; if (inCodeBlock) { splitIndex i; // 标记代码块开始 } } } if (inCodeBlock splitIndex -1) { const before lines.slice(0, splitIndex).join(\n); const codeBlock lines.slice(splitIndex).join(\n); return { renderedPart: before, codeBlock }; } return { renderedPart: rawText, codeBlock: }; }渲染时把before交给 Markdown 解析后插入容器再把codeBlock用textContent塞进一个pre。这样既保证了已完整部分的美观也避免了未闭合代码块被错误渲染。缺点是代码块内部如果也有 Markdown 样式在前几行没闭合前不会渲染高亮但这反而是合理的因为你不能保证中途截断的代码是语法正确的。表格的处理类似。如果最后一个|和下一行没有对齐或者表格行尚未结束我可以先整段展示成普通文本等收到换行符或者表格完整闭合后再对表格区域做一次局部重渲染。这里的关键是“局部重渲染”不要去动前面已经稳定的 DOM。4.3 光标与自动滚动细节打字机效果少不了一个跳动的光标。最简单的方式是在文本末尾放一个span用 CSS 动画控制透明度闪烁.typing-cursor { display: inline-block; width: 8px; height: 1em; background: #333; vertical-align: text-bottom; animation: blink 0.8s steps(1) infinite; } keyframes blink { 50% { opacity: 0; } }光标要放在文本流末尾如果回答里包含 Markdown你要把光标放在渲染后的 DOM 末尾而不是原始文本末尾。不然会出现“文本已经有 500 字光标却还在内容中间”的错觉。自动滚动也是打字机渲染里的刚需。用户阅读时通常希望新内容出现时页面自动滚到底部。但用户一旦手动往上滚动查看历史内容自动滚动就不能再强制拉到底部否则会非常烦人。我用的方案是监听scroll事件并判断是否靠近底部let isNearBottom true; chatBox.addEventListener(scroll, () { const { scrollHeight, scrollTop, clientHeight } chatBox; isNearBottom scrollHeight - scrollTop - clientHeight 80; }); function appendContent() { // 渲染增量... if (isNearBottom) { chatBox.scrollTop chatBox.scrollHeight; } }判断阈值我一般设置在 80 到 120 像素之间太大会导致用户稍微上滑就被强制拉回太小又可能出现内容还没看到就被滚动跳过。实际体验下来 80px 是个比较稳妥的值。还有一个性能细节不要让每次scrollTop scrollHeight都触发强制同步布局。可以把这个操作包在requestAnimationFrame里或者用scrollTo({ top: scrollHeight, behavior: auto })避免平滑滚动带来的连续重绘。平滑滚动在连续大量文本下会非常卡。4.4 数据层与渲染层解耦代码写到后期你会发现如果所有逻辑都揉在一个组件里状态会非常混乱。我在项目里把 SSE 接收、断点续传、打字机渲染拆成了三个独立模块用事件发布订阅串起来。const aiStream new AIStreamClient({ onRawChunk: (chunk) { // 1. 数据层累积原始文本 sessionStore.append(chunk.text); // 2. 通知渲染层去取增量 renderScheduler.push(chunk.text); }, onReconnect: () { renderScheduler.pause(); }, onResumeDone: () { renderScheduler.resume(); }, });这样做的收益很明显我可以在不碰渲染逻辑的前提下随时替换后端协议也可以在测试里只给数据层喂假 chunk验证断点续传逻辑是否可靠。渲染层只关心一件事从 buffer 里拿增量更新屏幕上的文字。另外如果页面里还有 AI 的状态展示比如“正在输入”“已断线重连中”也应该是基于状态机而不是散落的布尔值。我维护一个streamStatusidle→connecting→streaming→reconnecting→resuming→done/error。任何 UI 组件都只管读取这个状态不要自己偷偷改标志位。5. 常见问题排查与避坑实录5.1 问题速查表下面是我开发中遇到最多的几类问题整理成速查表方便大家直接检索。症状可能原因解决方案中文汉字变成乱码TextDecoder没传{ stream: true }多字节字符被拆包在 decode 时开启 stream 模式并使用 buffer 累积SSE 事件解析不完整只按\n切分没有等空行统一按\n\n或归一化\r\n后切分流结束后重复追加内容没有把[DONE]和连接关闭都当作结束增加 finish 标志在回调里清空 buffer 和定时器断线后重连生成重复文本模型从开头重新生成或续写时重复上文使用“重叠检测”裁剪重复部分页面渲染越来越卡每个 chunk 都全量 setState 并重新解析 Markdown引入缓冲队列定时批量渲染分块局部更新未闭合的代码块样式错乱Markdown 增量解析不完整检测代码块未闭合时只用 textContent 放入pre用户向上滚动又被拉回底部自动滚动判断阈值过小或没有判断用户滚动方向通过 scroll 事件维护isNearBottom超过阈值才自动滚断线重连后界面卡在“连接中”流已结束但前端未触发 done 回调在 finally 里统一调用 finish 逻辑5.2 调试SSE流的几个实用技巧写 SSE 客户端最容易遇到“我在浏览器里看不到问题但接口直接调就是好的”这种尴尬。这个时候最快的验证方式是用 curl 直接看原始流curl -N -X POST https://your-api.com/chat/stream \ -H Content-Type: application/json \ -H Authorization: Bearer xxx \ -d {message:你好}-N表示不要缓冲输出这样你能看到每一块数据是不是按预期到达也能直接验证data:和[DONE]的顺序。如果你在 curl 里能看到正常流但浏览器里解析不出内容那问题多半出在 Decoder 或拆帧逻辑上。还有一个技巧在开发环境用ReadableStream模拟一个假流专门测试打字机渲染和断点续传。这样不需要每次都真的调大模型测试稳定性高很多。function createMockSSEStream(parts, { interval 50 } {}) { return new ReadableStream({ async start(controller) { for (const part of parts) { controller.enqueue(new TextEncoder().encode(data: ${JSON.stringify(part)}\n\n)); await new Promise((r) setTimeout(r, interval)); } controller.enqueue(new TextEncoder().encode(data: [DONE]\n\n)); controller.close(); }, }); }我把这个 mock 流封装进了测试工具只要在环境变量里开一个USE_MOCK_AI1前端就能自动启用模拟数据。对于调试 UI 和重连逻辑这套方案比连真实 API 香太多。5.3 我认为最值钱的经验心得第一不要把“流式传输”和“流式展示”混为一谈。传输层就是老实把数据拿回来展示层是用户看到的效果两者之间一定要有缓冲和状态管理。很多人把两个概念揉在一起导致改 UI 时很痛苦改传输时更痛苦。第二断点续传的设计一定要前置不要等线上出问题再补。你无法预测用户网络环境尤其是移动端进电梯、过隧道几秒钟就断一次。如果设计时没考虑重试和续写用户只会看到一个转圈半天、最后啥也没有的答案这是 AI 产品里最败好感的事。第三打字机渲染不是越快越好。曾经我把输出间隔调到了 10ms视觉上几乎是一瞬间把所有文字打出来结果用户根本看不清中间的推导过程。后来我把输出速度控制在每秒 20 到 30 个 token 的视觉节奏配合光标闪烁用户反馈“看起来更智能”。这其实涉及预期管理适当的延迟反而让 AI 显得在“思考”。第四测试时一定要覆盖“低网速 大响应”场景。用 Chrome DevTools 的网络节流功能模拟 Slow 3G再让 AI 生成一篇长文观察是否有乱码、卡顿、断流。我在这个场景下抓到过好几个只在生产环境出现的诡异 Bug比如 buffer 一直不消费导致内存上涨以及自动滚动在小屏幕手机上失灵的问题。我在实际项目里做得最多的一件事就是反复模拟“用户网络很差”的情况然后把每次断线都当成一次新的压测机会。等断点续传和打字机渲染扛住最恶劣的网络环境后再回头看正常的网络体验基本就是锦上添花。做 AI 前端扎实的基础功比花哨的动画重要得多。希望这篇实践笔记能让你少走点弯路。