1. 从事件绑定到流式响应:前端人做AI应用的第一道认知门槛
前端开发者转做AI应用,最容易低估的不是模型本身,而是数据从服务端到浏览器的那条链路。传统CRUD页面里,请求发出、等待、拿到完整JSON、渲染,这套心智模型根深蒂固。但AI对话场景下,用户期望的是打字机效果——文字像有人在另一端敲键盘一样逐字浮现。这个体验差异背后,是HTTP响应模型从"一次性交付"到"持续流式推送"的根本转变。
我最初做这个Demo时,第一反应是调完接口拿到完整回复再逐字显示。实测下来,用户等待首字出现的时间可能长达数秒,体验极差。真正的流式对话要求服务端每生成一个token就立即推送,前端收到即渲染。这需要理解SSE(Server-Sent Events)或ReadableStream的消费方式,而不是简单的fetch().then()。
这个Demo的价值在于:它用最小的技术栈,把"前端如何消费AI流式输出"这件事讲透。适合有JavaScript基础、想切入AI应用开发但不知从何下手的前端工程师。你不需要懂模型训练,不需要买GPU,只需要一个能返回流式响应的API端点,就能跑通整条链路。
2. 为什么选SSE而不是WebSocket:流式对话的传输层决策
2.1 两种流式方案的适用边界
做流式对话,传输层有两个主流选择:WebSocket和SSE。很多教程一上来就推WebSocket,理由是"全双工、更灵活"。但实际做AI对话Demo,SSE才是更合理的选择。
WebSocket适合双向高频通信场景,比如多人协作编辑、实时游戏。AI对话的本质是:客户端发一次请求,服务端持续推送多个数据块,直到结束。这是典型的单向流式推送,SSE天然匹配。用WebSocket反而要自己处理消息分帧、心跳保活、重连逻辑,复杂度陡增。
SSE基于普通HTTP协议,浏览器原生支持EventSource接口,服务端只需设置Content-Type: text/event-stream,然后按格式持续写入数据即可。对于前端转AI的开发者来说,SSE的学习成本几乎为零——你只需要理解响应体是一个持续可读的流。
2.2 前端消费SSE的两种姿势
EventSource是最简单的SSE消费方式,但它只支持GET请求,无法携带复杂的请求体。AI对话通常需要POST发送消息历史、模型参数等,所以实际项目中更常用fetch + ReadableStream的方式。
const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: history }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); // 处理chunk,解析SSE格式 }这段代码是流式对话Demo的核心。reader.read()返回一个Promise,每次resolve时拿到一个Uint8Array数据块。decoder.decode的{ stream: true }参数很关键——它告诉解码器当前数据块可能不完整,需要保留状态等待后续字节,否则中文字符被截断时会出现乱码。
2.3 SSE数据格式的解析细节
服务端推送的SSE数据有固定格式:每行以data:开头,两个换行表示一个事件结束。但实际网络传输中,一个TCP包可能包含多个事件,也可能一个事件被拆到多个包。所以不能假设每次reader.read()拿到的就是一个完整事件。
稳妥的做法是维护一个缓冲区,按\n\n分割,最后一段留在缓冲区等待下次拼接:
let buffer = ''; // 在循环内 buffer += chunk; const parts = buffer.split('\n\n'); buffer = parts.pop(); // 最后一段可能不完整 for (const part of parts) { const line = part.replace(/^data: /, ''); if (line === '[DONE]') { /* 结束 */ } else { const parsed = JSON.parse(line); // 追加到界面 } }这个缓冲区处理逻辑是很多新手会踩的坑。我见过有人直接对每个chunk做JSON.parse,结果频繁报错,因为chunk边界和事件边界不对齐。
3. 从零搭建Demo:环境准备与最小可运行架构
3.1 技术选型:为什么用原生JS而不是框架
这个Demo我刻意用原生HTML+JS写,不引入React或Vue。原因很简单:转AI开发的前端,此时最需要理解的是流式数据的处理逻辑,而不是框架的响应式更新。框架的抽象层会掩盖掉reader、decoder、buffer这些关键概念。等你把原生版本跑通了,再迁移到框架里,只是把DOM操作换成状态更新而已。
服务端用一个极简的Node.js HTTP服务,不依赖任何AI SDK,直接模拟流式输出。这样你能看清SSE的原始格式,而不是被SDK封装后的黑盒。
3.2 服务端最小实现
const http = require('http'); http.createServer((req, res) => { if (req.url === '/api/chat' && req.method === 'POST') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); const text = '这是一个模拟的AI流式回复,用于演示前端如何逐字渲染。'; let i = 0; const timer = setInterval(() => { if (i >= text.length) { res.write('data: [DONE]\n\n'); clearInterval(timer); res.end(); return; } const char = text[i]; res.write(`data: ${JSON.stringify({ content: char })}\n\n`); i++; }, 50); req.on('close', () => clearInterval(timer)); } }).listen(3000);几个关键点:Cache-Control: no-cache防止代理缓存流式响应;req.on('close')清理定时器,避免客户端断开后服务端还在空转;每个数据块用JSON.stringify包裹,防止内容里有换行符破坏SSE格式。
3.3 前端页面骨架
前端只需要一个消息列表容器、一个输入框、一个发送按钮。核心逻辑在发送按钮的点击处理里:把用户输入追加到界面,然后发起fetch请求,进入流式读取循环。
界面渲染上有个细节:AI回复的气泡应该先创建出来,然后随着流式数据不断更新其textContent。不要每收到一个字就重新创建DOM节点,那样性能差且会导致光标闪烁。
const aiBubble = document.createElement('div'); aiBubble.className = 'message ai'; chatContainer.appendChild(aiBubble); // 在流式循环内 aiBubble.textContent += parsed.content; chatContainer.scrollTop = chatContainer.scrollHeight;自动滚动到底部是必须的,否则用户看不到最新内容。但要注意,如果用户手动往上滚动了,就不应该强制拉回底部——这个体验细节后面会展开。
4. 流式渲染中的五个真实坑与修复方案
4.1 中文乱码:TextDecoder的stream参数
前面提过{ stream: true },这里展开说。UTF-8编码的中文字符占3个字节。如果网络传输把一个中文字符的3个字节拆到了两个chunk里,第一个chunk用decoder.decode(value)解码时,末尾会得到替换字符。加上{ stream: true }后,解码器会保留不完整的字节序列,等下一个chunk到来时拼接后再解码。
这个坑的隐蔽性在于:英文测试完全正常,一上中文就偶尔出现乱码,而且不是必现,取决于网络分包。排查时可以在decoder前后打印字节长度和字符长度对比。
4.2 缓冲区边界:一个事件被拆成两半
SSE事件以\n\n结尾。如果reader.read()返回的chunk刚好在\n\n中间截断,直接split就会把半个事件当成完整事件处理。解决方案就是前面说的缓冲区:每次拼接后split,pop出最后一段留到下次。
注意:不要用
split('\n\n')后直接遍历所有部分,因为最后一部分可能是残缺的。必须pop出来存回缓冲区。
4.3 滚动锁定:用户上翻时不要强制拉底
流式输出时,如果用户想回看前面的内容,往上滚动,此时新内容不断追加,如果代码里无条件执行scrollTop = scrollHeight,用户会被强行拽回底部,体验非常糟糕。
正确的做法是判断用户当前是否在底部附近:
const isNearBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight < 50; if (isNearBottom) { chatContainer.scrollTop = chatContainer.scrollHeight; }这个50px的阈值是经验值,太小会导致稍微滚动就失去自动跟随,太大则用户明明上翻了还被拉回。
4.4 请求中断:AbortController的正确使用
用户可能在AI还在回复时就想停止,或者发送新消息。此时需要中断上一个流式请求。fetch支持AbortController:
const controller = new AbortController(); fetch('/api/chat', { signal: controller.signal, ... }); // 需要中断时 controller.abort();中断后,reader.read()会抛出AbortError,需要在catch里识别并静默处理,不要弹错误提示。同时服务端的req.on('close')会触发,清理定时器。
4.5 错误处理:流中途断开怎么办
网络不稳定时,流可能中途断开。此时已经渲染的部分内容应该保留,同时在末尾追加一个错误提示,而不是清空整个气泡。我见过一些实现直接aiBubble.textContent = '出错了',把用户已经看到的内容抹掉,这很不友好。
更好的做法是保留已有内容,在气泡下方加一行小字提示"回复中断",并提供一个重试按钮。
5. 从Demo到可用:消息历史管理与上下文拼接
5.1 为什么需要维护消息数组
单轮对话的Demo跑通后,下一步就是多轮对话。AI模型本身是无状态的,它不知道上一轮说了什么。所以每次请求都需要把完整的历史消息数组发给服务端,服务端再转发给模型。
前端需要维护一个messages数组,结构通常是[{ role: 'user', content: '...' }, { role: 'assistant', content: '...' }]。每次用户发送新消息,push进去;AI回复完成后,把完整回复也push进去。
5.2 流式回复的拼接时机
流式过程中,AI的回复是逐字到达的。你不能每收到一个字就往messages数组里push一条,那样数组会爆炸。正确做法是:流开始时创建一个空的assistant消息对象,流过程中不断更新它的content,流结束后这个对象就是完整的回复。
const assistantMsg = { role: 'assistant', content: '' }; messages.push(assistantMsg); // 流式循环内 assistantMsg.content += parsed.content;这样messages数组始终是干净的对话历史。
5.3 上下文长度与截断策略
多轮对话跑久了,messages数组会越来越长,最终超出模型的上下文窗口。前端需要做截断:保留最近的N轮对话,或者按token数估算截断。
简单策略是保留最近10轮(20条消息),更早的丢弃。但要注意,system prompt(系统提示词)通常要始终保留在数组开头。如果丢弃了system prompt,模型的行为会突变。
const MAX_ROUNDS = 10; if (messages.length > MAX_ROUNDS * 2 + 1) { const systemMsg = messages[0]; const recent = messages.slice(-(MAX_ROUNDS * 2)); messages = [systemMsg, ...recent]; }这个截断逻辑在每次发送请求前执行一次即可。
6. 界面细节决定体验:打字光标、Markdown渲染与代码块
6.1 打字光标的实现
流式输出时,在AI气泡末尾显示一个闪烁的光标,能显著提升"正在输入"的感知。实现很简单:在气泡内追加一个span,用CSS动画控制闪烁,流结束后移除。
.cursor { display: inline-block; width: 2px; height: 1em; background: #333; animation: blink 1s step-end infinite; vertical-align: text-bottom; } @keyframes blink { 50% { opacity: 0; } }注意光标要放在textContent之后,且流结束后要remove掉,否则会一直闪。
6.2 Markdown渲染的时机问题
AI回复经常包含Markdown格式,比如代码块、列表、加粗。如果流式过程中实时渲染Markdown,会遇到一个问题:不完整的Markdown语法会导致渲染错乱。比如**加粗还没等到闭合的**,渲染器可能把后面的内容都当成加粗。
两种策略:一是流式过程中只做纯文本显示,流结束后再整体渲染Markdown;二是流式过程中用轻量渲染,只处理换行和代码块标记,流结束后完整渲染。我倾向于第一种,实现简单且不会出现中间态错乱。
6.3 代码块的复制按钮
如果AI回复里有代码块,用户大概率想复制。在渲染完成的Markdown代码块上,用JS遍历pre > code元素,动态插入一个复制按钮。点击时用navigator.clipboard.writeText写入剪贴板。
这个功能虽小,但在实际使用中频率极高,属于"没有会难受"的细节。
7. 性能与稳定性:长对话下的优化经验
7.1 虚拟滚动是否必要
当对话轮次达到几十轮,消息列表的DOM节点数可能上百。此时滚动会开始卡顿。虚拟滚动(只渲染可视区域内的消息)是解决方案,但实现复杂度较高。对于Demo阶段,我的建议是先不做,等真的遇到性能瓶颈再上。大多数场景下,几十条消息的DOM渲染在现代浏览器上完全流畅。
如果确实需要优化,可以先从减少重排入手:流式更新时只改textContent,不触发布局变化;消息气泡用contain: content限制重绘范围。
7.2 内存泄漏的排查
流式请求如果没正确清理,会导致内存泄漏。重点检查:AbortController是否在组件卸载时abort;reader是否在流结束后releaseLock;定时器是否清理。在浏览器DevTools的Memory面板里,反复发送消息后看内存是否持续增长,是有效的排查手段。
7.3 弱网下的重试策略
网络不稳定时,流式请求可能失败。简单的重试是直接重新发送整个请求,但这样会丢失已经渲染的部分。更好的做法是记录已接收的内容长度,重试时带上这个偏移量,让服务端从断点继续。不过这需要服务端支持,Demo阶段可以先做整体重试,并在界面上保留已接收内容,提示用户"重新生成"。
8. 转AI开发,这个Demo之后该往哪走
把流式对话Demo跑通,你其实已经跨过了前端转AI最核心的那道坎:理解了AI应用的交互本质是流式数据消费。接下来有几个方向可以深入。
一是接入真实模型API。把服务端的模拟流替换成真实模型的调用,你会接触到API Key管理、请求限流、token计费等工程问题。二是做多模态,图片、语音的流式传输和处理逻辑与文本类似,但数据格式不同。三是做RAG(检索增强生成),在发送请求前先检索本地知识库,把相关片段拼进上下文,这是当前AI应用最主流的落地形态。
我在实际项目里发现,前端做AI应用的优势在于对交互细节的敏感度。模型能力是后端和算法的事,但用户感知到的智能程度,很大程度上取决于前端如何处理流式渲染、错误恢复、上下文管理这些"最后一公里"的问题。这个Demo虽小,但把这条链路上的关键节点都摸了一遍,后续做更复杂的AI产品时,这些经验会反复用到。
最后分享一个调试技巧:在流式循环里加一行console.log(performance.now(), chunk.length),可以直观看到每个数据块的到达时间和大小。当用户反馈"回复卡顿"时,这行日志能帮你快速判断是网络问题还是渲染问题——如果chunk到达间隔均匀但界面更新慢,就是渲染瓶颈;如果chunk本身间隔就大,那就是服务端或网络的问题。