接入大模型流式输出后,文字偶尔乱码?问题可能出在前端

最近做 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 应用时,模型调用只是其中一环。请求取消、异常处理、页面更新和数据格式校验这些看似普通的工程细节,同样会直接影响最终的使用体验。

相关推荐
csj502 小时前
前端基础之《React(16)—Modal组件封装》
前端·react.js
雨季mo浅忆2 小时前
Vue关于前端XSS漏洞修复方案
前端·xss
切糕师学AI2 小时前
el-select-v2 深度解析:用虚拟滚动解决海量数据下拉选择性能瓶颈
前端·vue.js·elementui·el-select-v2
徐小夕3 小时前
JitOffice:把办公、AI 与审批装进同一个平台
前端·vue.js·github
徐小夕3 小时前
万字分享:为什么我们要自研Word解析引擎?JitWord 解析引擎 4.0 深度复盘
前端·vue.js·github
陈随易3 小时前
副业搞钱点子之独立开发
前端·后端·程序员
console.log('npc')3 小时前
问题跟踪与知识沉淀:从任务到知识的闭环)
前端·javascript·人工智能
恋猫de小郭3 小时前
最近 Android 有什么更新:Android Dev Summit 2026 技术解读
android·前端·flutter
计算机魔术师3 小时前
Reddit 宣布转向以登录为主的体验,以应对 AI 搜索带来的流量下滑
前端