外观
前端集成 LLM 完整实现:流式响应、打字机效果与中断恢复一次写对
给产品加一个 AI 对话框,第一天就能跑通 demo,第三天开始出问题:中文偶尔乱码、回答很长时界面卡顿、用户点了停止按钮后对话状态错乱、弱网下断线整轮回答作废。这些都是 LLM 流式集成特有的坑——传统请求「一问一答一响应体」的心智模型,在「边生成边下发」的流式世界里处处不适用。
本文基于 Vue3 组合式函数,把流式协议、SSE 解析、打字机渲染、中断与恢复一次写对,代码可直接用于聊天类界面。
先搞清协议:为什么必须服务端转发
动手前先定架构。LLM 的 API Key 绝不能出现在前端,所以浏览器不能直连模型服务商,必须由自己的服务端做一层转发。转发层要做的关键动作是把上游的流式响应原样透传给浏览器(以 Node/Express 为例,关闭代理缓冲、以 text/event-stream 返回),否则流会在网关被攒成一整包,前端拿到的「流」其实是最后一秒倾泻而下的大块文本,打字机效果全毁。
浏览器侧的传输方案对比:
| 方案 | 能力 | 适用性 |
|---|---|---|
| EventSource | 原生 SSE,仅 GET、不能带自定义头 | 对话需要 POST 大段上下文,不适用 |
| fetch + ReadableStream | 完全控制请求与流读取 | 主流选择 |
| WebSocket | 双向通道 | 需要双向实时(语音)才值得引入 |
对话场景选 fetch 流式读取:请求是 POST(要带完整对话历史),响应是 SSE 格式的文本流。
实操:SSE 解析的正确姿势
SSE 的报文是多行 data: xxx 文本,一个网络 chunk 里可能包含半条消息、一条完整消息甚至几条消息。解析器的两大纪律:用带 stream 选项的 TextDecoder(中文等多字节字符可能被切断在 chunk 边界),维护缓冲区按行切分(不完整的行留待下一个 chunk)。封装成异步生成器:
ts
// composables/useChatStream.ts 核心片段:SSE 流解析
async function* parseSSE(res: Response) {
const reader = res.body!.getReader();
// stream: true 是关键:多字节中文被切断在 chunk 边界时不会乱码
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // 末尾可能是半条消息,留在缓冲区等下一个 chunk
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue; // 忽略注释行与空行
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") return; // 结束标记(OpenAI 兼容格式)
yield payload; // 每条是一个 JSON:{ choices: [{ delta: { content } }] }
}
}
}调用侧把增量文本追加进会话状态:
ts
const answer = ref("");
let controller: AbortController | null = null;
async function send(messages: { role: string; content: string }[]) {
answer.value = "";
controller = new AbortController();
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal: controller.signal // 中断的能力从这里注入
});
for await (const payload of parseSSE(res)) {
const delta = JSON.parse(payload).choices?.[0]?.delta?.content ?? "";
answer.value += delta; // 逐 token 追加,天然打字机
}
} catch (err) {
if (err.name === "AbortError") return; // 用户主动中断,不算错误
console.error("[对话] 流式请求失败:", err);
} finally {
controller = null;
}
}打字机效果:真流式不需要 setInterval
最常见的误区是用定时器「假装」逐字输出。真正的流式响应本身就是逐 token 到达的,前端要做的是两件配套的事,而不是制造节奏:其一,追加渲染要跟得上——直接 answer.value += delta 即可;其二,Markdown 解析要节流——每个 token 都全量 parse 一遍长回答,主线程会被 markdown 解析吃满。把「渲染用的文本」与「接收缓冲」分离,用节流钩子:
ts
import { refDebounced } from "@vueuse/core";
const rawAnswer = ref(""); // 接收侧:每 token 直接追加
const renderText = refDebounced(rawAnswer, 80); // 渲染侧:80ms 节流后交给 markdown-it80~120ms 的节流人眼完全无感,Markdown 解析频次却下降一个数量级。另有一个细节:流式中途的文本可能包含未闭合的代码块围栏,直接渲染会导致半篇内容样式跳变;渲染前给未闭合的 ``` 补一个收尾围栏即可容错。
中断与恢复:把 AbortController 用对
中断的体验标准是三件事:点击停止后已生成的内容保留并标记「已中断」;网络层真正断开(不是前端装样子,请求继续在后台烧 token);可以「继续生成」。
ts
function stop() {
// abort 让 fetch 立刻拒绝,连接真正断开,服务端可据此停止上游生成
controller?.abort();
controller = null;
// 已收到的 answer.value 保留在会话里,并追加「已中断,可点击继续」标记
}「继续生成」的实现依赖服务端会话:对话历史与已生成内容存在服务端 session(以 session_id 关联),继续时前端发送「从中断处续写」的指令,服务端携带完整上下文重新调用模型。弱网断线的恢复同理:客户端凭 session_id 重连,服务端返回会话状态——上下文没丢,体验就是连续的。若服务端是自研网关,还能做到按已接收 offset 续传真正的流,但多数场景「重放会话重新生成」已经够用。
界面的最后一公里还有成本防线。上下文裁剪是最有效的一项:对话历史不能无脑全量透传,超过阈值的早期轮次要么丢弃要么摘要化,既省 token 又防上下文窗口溢出;max_tokens 上限与前端输入长度限制(超长时硬截断并明确提示)在服务端与前端各设一层。防刷依赖服务端:按用户与会话限流、异常频率触发验证码,前端只做体验层的引导——禁用连点、展示剩余额度。这些逻辑看似与流式渲染无关,但 AI 功能一上线就会被真实流量考验,上线前把开关留好,比事后救火从容得多。
避坑总结
- TextDecoder 不带 stream: true 必乱码。多字节 UTF-8 字符被切在 chunk 边界时,逐 chunk 解码会产生「�」,这是中文对话界面最高频的 bug;解码器必须跨 chunk 持有并开 stream 选项。
- 每条 SSE 消息单独 try/catch。上游偶尔会发出不完整或异常的 JSON 片段,一条解析失败不该中断整轮对话;yield 出来的 payload 在消费侧包 try/catch,坏消息跳过并记日志。
- 自动滚动要尊重用户意图。无脑滚到底会让「上滑看历史」的用户被反复拽回底部;监听滚动位置,只有用户本就在底部附近时才跟随滚动,上滑阅读时停止跟随。
- AbortError 走正常分支。用户点停止触发的异常与真实网络错误必须分流处理,前者安静地收尾会话,后者才提示错误;把中断当错误 toast 出来是新手期最常见的体验事故。
- 中断要传到服务端。只在前端「不管结果了」而不断开连接,上游模型还在生成,token 在白烧;abort 让连接真正关闭,服务端监听 close 事件同步终止上游请求,省钱又省算力。
写在最后
LLM 流式集成的所有细节,归结起来是两种思维的转换:请求从「等完整结果」变成「消费增量流」,错误从「成败二元」变成「中断、断线、坏包多态处理」。SSE 解析器守住字符与消息边界,渲染层节流守住帧率,会话层守住中断与恢复的连续性——三层各自做对,打字机般的对话体验才立得住。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。