最近做 AI 聊天界面时,很多人都会选择流式输出。用户发送问题后,回答不是一次性全部显示,而是像打字一样逐步出现。
这种交互体验确实不错,但实现起来有几个容易忽略的细节。
最常见的情况是:接口明明返回正常,前端显示的内容却偶尔出现乱码;有时 JSON 解析失败,有时一段完整的文字被拆成好几截。
如果你也遇到过这些问题,可以先检查一下前端读取响应流的方式。
一、为什么接口返回正常,前端却可能出错?
先看普通请求。
javascript
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
message: "解释一下什么是 RAG"
})
});
const data = await response.json();
console.log(data);
这种方式适合接口一次性返回完整 JSON 的情况。
但如果后端采用流式输出,响应可能会持续传输一段时间,期间不断发送新的数据。此时就不能简单地把整个响应当成一个完整的 JSON 对象来解析。
这里有三个概念需要区分:
- Token:大模型生成文本时使用的基本处理单位,不一定对应一个汉字或一个单词。
- 网络数据块(Chunk):客户端每次从网络读取到的一段数据。
- SSE 事件:服务器按照 Server-Sent Events 格式发送的一条消息。
它们并不是一回事。
例如,模型生成了"你好,今天怎么样?",网络可能分几次传输,某一次读取甚至可能只拿到半个汉字对应的 UTF-8 字节。
如果每次读取后都直接调用 TextDecoder 解码,而且没有保留未完成的字符,就有可能出现乱码。
同样,如果认为每次读取到的数据都是完整的 JSON 或完整的 SSE 事件,也容易导致解析失败。
二、先解决中文乱码问题
使用 fetch 读取流时,可以通过 response.body.getReader() 获取读取器,再配合 TextDecoder 逐步解码。
javascript
async function readStream(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
if (!response.body) {
throw new Error("当前环境不支持读取响应流");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
const text = decoder.decode(value, {
stream: true
});
console.log(text);
}
// 处理解码器内部剩余的字节
console.log(decoder.decode());
} finally {
reader.releaseLock();
}
}
重点在这一行:
javascript
decoder.decode(value, { stream: true });
设置 stream: true 后,如果当前字节块末尾是一个尚未接收完整的 UTF-8 字符,解码器会保留相关数据,等后续字节到达后再完成解码。
循环结束后再调用一次 decoder.decode(),可以处理解码器内部尚未输出的数据。
不过,这段代码只解决了字节解码问题,还没有解决 SSE 事件的解析问题。
三、SSE 不能按网络数据块直接解析
很多大模型接口使用 SSE 返回流式结果。
典型的数据格式如下:
text
data: {"choices":[{"delta":{"content":"你好"}}]}
data: {"choices":[{"delta":{"content":","}}]}
data: {"choices":[{"delta":{"content":"世界"}}]}
data: [DONE]
实际传输时,多个事件可能被放进同一个网络数据块里,也可能一个事件被拆到多个数据块中。
因此,不能直接写成:
javascript
const text = decoder.decode(value);
const data = JSON.parse(text);
这样写有两个问题:
第一,text 可能只包含半条消息,根本不是合法的 JSON。
第二,text 也可能同时包含多条 SSE 消息,不能直接当成一个 JSON 对象解析。
正确做法是先把数据按 SSE 的事件边界拼接起来,再逐条处理。
四、写一个简单的 SSE 流式解析器
下面以常见的 OpenAI 兼容接口返回格式为例,实现一个简化版本。
这里假设后端已经提供了 /api/chat/stream 接口,并且返回标准 SSE 格式。前端不直接保存或暴露模型服务商的 API Key。
javascript
async function streamChat(message, onText) {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message })
});
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
if (!response.body) {
throw new Error("当前环境不支持流式响应");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finished = false;
function processEvent(eventText) {
const dataLines = [];
for (const line of eventText.split(/\r\n|\r|\n/)) {
// 忽略注释和非 data 字段
if (line.startsWith(":")) continue;
if (line.startsWith("data:")) {
let value = line.slice(5);
if (value.startsWith(" ")) {
value = value.slice(1);
}
dataLines.push(value);
}
}
if (dataLines.length === 0) return;
const data = dataLines.join("\n");
if (data === "[DONE]") {
finished = true;
return;
}
let payload;
try {
payload = JSON.parse(data);
} catch {
throw new Error("SSE 事件中的 JSON 格式不正确");
}
const text = payload.choices?.[0]?.delta?.content;
if (typeof text === "string" && text.length > 0) {
onText(text);
}
}
try {
while (!finished) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, {
stream: true
});
// 空行代表 SSE 事件结束,兼容 LF、CRLF 和 CR
const events = buffer.split(/\r\n\r\n|\n\n|\r\r/);
// 最后一段可能只是半个事件,留到下一轮
buffer = events.pop() ?? "";
for (const eventText of events) {
processEvent(eventText);
if (finished) break;
}
}
// 刷新解码器
buffer += decoder.decode();
// 处理流结束前剩下的最后一条事件
if (!finished && buffer.trim()) {
processEvent(buffer);
}
} finally {
reader.releaseLock();
}
}
调用方式也很简单:
javascript
const output = document.querySelector("#output");
await streamChat("解释一下 RAG 的工作原理", (text) => {
output.textContent += text;
});
这里的 #output 是页面上用于显示回答的元素。
需要注意,这个示例是针对常见 SSE 格式编写的简化解析器。正式项目如果需要处理任意格式的 SSE、事件 ID、重连、复杂换行和自定义事件类型,最好使用成熟的 SSE 解析库。另外,接口返回的事件结构也需要与实际使用的模型服务保持一致。
五、还有几个细节容易被忽略
1. 不要直接用 innerHTML 拼接模型输出
流式输出时,经常会看到这样的代码:
javascript
output.innerHTML += text;
这不太合适。
一方面,频繁修改 innerHTML 会反复解析已有内容;另一方面,模型输出可能包含不可信的 HTML 字符串,直接插入页面会带来安全风险。
如果只是显示普通文本,优先使用:
javascript
output.textContent += text;
如果需要 Markdown、代码高亮或数学公式,可以先收集文本,再通过经过安全处理的 Markdown 渲染器展示。流式渲染时也要注意未完成的 Markdown 语法。
2. 用户中途离开页面怎么办?
如果用户发送第二条消息,或者主动点击停止按钮,前一个请求可能还在持续传输。
可以使用 AbortController 取消请求:
javascript
const controller = new AbortController();
fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
message: "解释一下向量数据库"
}),
signal: controller.signal
});
// 用户点击停止按钮时
controller.abort();
真实项目中,需要把 controller 保存到当前会话对应的状态中,并在取消时同步停止读取和更新界面的操作。
还要注意,取消浏览器请求不一定代表后端或模型服务立即停止生成。如果需要真正中止推理,后端还要处理断开连接和取消任务的逻辑。
3. 别把每个 Token 都当成一次页面更新
如果每次收到一点文本就执行复杂的 Markdown 渲染,长回答可能出现明显的卡顿。
简单的纯文本追加通常问题不大。如果还需要代码高亮、表格、公式和复杂布局,可以考虑批量更新界面,或者限制重新渲染的频率。
六、最后总结一下
接入大模型流式输出,真正容易出问题的地方不只是接口调用,而是整个数据处理过程。
网络读取负责拿到字节,TextDecoder 负责正确解码,SSE 解析器负责识别事件,业务代码再提取模型返回的文本并更新界面。
把这些步骤分清楚,乱码、JSON 解析失败和旧数据混入等问题就更容易定位。
开发 AI 应用时,模型调用只是其中一环。请求取消、异常处理、页面更新和数据格式校验这些看似普通的工程细节,同样会直接影响最终的使用体验。