从 “卡死半天” 到 “打字机效果”:大模型流式输出前端实现全记录

上周我想搭个极简的大模型测试页,方便自己调 prompt 用。三下五除二写完第一版,点提交的瞬间我自己都皱眉头 ------ 输入框敲完问题,点一下按钮,页面就钉死在 "思考中...",短问题还好,稍微长一点的生成,好几秒没动静,不知道的还以为页面崩了。

当时我就想,人家 ChatGPT 那种打字机一样的效果,到底是怎么做出来的?总不能是前端故意做个打字动画吧?真要那样,反而更慢了。带着这个疑问,我把 DeepSeek 的 API 文档翻了一遍,又对着流式响应的代码啃了一晚上,总算是把这事儿捋明白了。

流式输出到底是个啥?我用大白话捋明白了

很多人第一次见流式输出,觉得挺神奇,点一下按钮字一个一个往外蹦,像有人在对面打字一样。其实拆开看特别朴素。

你就这么想:普通的接口请求,就像你去饭店点菜,厨房把整道菜做好了,服务员才端给你。等菜的过程你啥也看不见,只能干坐着。流式输出呢,就像去吃铁板烧,厨师煎好一块就给你夹一块,你边吃他边做。总耗时其实没差多少,但你不用干等,全程都有反馈,体感上就快了很多。

放到技术上讲,大模型生成内容本来就不是一下子算完的,它是一个 token 一个 token 往后推的(你可以粗暴理解成一个字或者半个字)。普通请求是等所有 token 都生成完,打包成一个 JSON 一次性返回给你;流式输出就是生成一个 token 就往连接里写一个,前端接收到一点就拼到页面上一点,看起来就成了打字机效果。

这里面其实没什么黑科技,就是两边提前约定好了:

  • 客户端请求的时候带个 stream: true,告诉服务端我要流式的,别攒着
  • 服务端收到之后,就不一次性返回了,保持连接不断,生成一点发一点
  • 数据格式也约定好,每一段数据都以 data: 开头,一行一条,结束的时候发个 data: [DONE] 标记

就这么简单。本质上还是 HTTP 请求,只是服务端不马上断开连接,持续往响应体里写数据而已。

先把基础的 Vue 页面搭起来

说回我的页面,我用 Vue3 写的,就一个单文件组件,特别简单。说实话我之前对 Vue 的组件化理解一直停留在 "就是把代码拆成文件",这次写小 demo 反而突然想通了。你就把每个.vue 文件当成一块乐高积木,这块积木自己带样子(template)、带逻辑(script)、带样式(style),拼到一起就是一整个页面。不用像以前那样 html、css、js 文件分开找,改一个功能就在一个文件里搞定,复用的时候直接把文件拷过去就行,确实省心。据说 Facebook 的主页就是上万个组件拼出来的,这么看组件化确实是大规模前端项目的刚需。

我这个组件就更简单了,一共就几个元素:一个输入框填问题,一个提交按钮,一个复选框控制开不开流式,下面一个区域展示返回的内容。

Vue 复制代码
<template>
  <div class="container">
    <label>输入:</label>
    <input type="text" class="input" v-model="question">
    <button @click="update">提交</button>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream">
      </div>
      <div>{{ content }}</div>
    </div>
  </div>
</template>

这里说句题外话,v-model 这东西我刚学的时候觉得挺玄乎,怎么绑一下就能双向同步了?后来才反应过来,就是个语法糖。你输入框改内容,它自动帮你监听 input 事件把值写回变量;变量变了,它自动把值同步到输入框上。省得你自己写value="xxx"再加个@input="xxx = $event.target.value",省事是真省事。

数据就用 ref 包一下,Vue3 的 script setup 语法糖是真的舒服,定义完变量模板里直接用,不用 return 来 return 去。

javascript 复制代码
import { ref } from 'vue';

const question = ref('讲一个关于龙的故事');
const stream = ref(false);
const content = ref('');

样式就随便写了个粉色背景,凑合用,反正就是自己测试用。

第一版:非流式请求,能跑但难用

最开始我写的就是最普通的 POST 请求,跟调普通接口没区别。把问题塞到 messages 里,fetch 发出去,等响应回来 parse 成 JSON,把内容取出来赋值给 content,完事。

javascript 复制代码
const update = async () => {
  if(!question.value) return;
  content.value = '思考中...';

  const response = await fetch(import.meta.env.VITE_DEEPSEEK_API_BASE_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: import.meta.env.VITE_DEEPSEEK_API_MODEL,
      messages: [{ role: 'user', content: question.value }],
      stream: false, // 关闭流式,一次性返回
    })
  });

  const data = await response.json();
  content.value = data.choices[0].message.content;
}

功能是没问题,点提交能出结果。但体验是真的差,尤其是生成长文本的时候,页面半天没反应,我都忍不住多点几次提交按钮。我当时就想,反正大模型也是一个字一个字生成的,为啥不早点给我呢?这就有了改成流式的想法。

改成流式:我踩的第一个大坑 ------ 中文乱码

说改就改,我把 stream 改成 true,然后去翻文档,说流式响应要读 response.body 的 reader。我第一版写得特别糙,拿到 reader 就循环读,解码完直接拼上去,心想这有啥难的。结果一跑,英文还好,只要输出中文,时不时就冒出几个乱码方块,时好时坏。我盯着屏幕懵了半天,啥情况?

后来查了半天才反应过来 ------UTF-8 编码里,一个中文占好几个字节。而 reader.read () 每次返回的字节块长度是不固定的,很不巧就会把一个汉字从中间劈开,前半字节在这一批,后半字节在下一批。你单独解码前半段,可不就是乱码吗。

怎么解决?两个关键点:第一,TextDecoder 解码的时候要加 { stream: true },告诉解码器我这是流式的,后面还有数据,遇到不完整的字符先别扔,存着等下一批。第二,要自己维护一个 buffer 缓冲区。因为 SSE 的数据是按行分隔的,每次读回来的内容可能最后一行是半截,要把半截行留到下一次循环再处理,不能直接拿去解析。

我改完之后的代码是这样的,关键地方我都标了:

javascript 复制代码
if (stream.value) {
  content.value = '';
  const reader = response.body?.getReader();
  // 坑来了:解码必须加 stream: true,不然中文大概率乱码
  // 别问我怎么知道的,试了三次才试出来
  const decoder = new TextDecoder();
  let done = false;
  let buffer = ''; // 存不完整的行,防止数据被截断

  while(!done) {
    const { value, done: doneReading } = await reader.read();
    done = doneReading;
    // 流式解码,不完整的字符内部会缓存
    buffer += decoder.decode(value, { stream: true });

    // 按换行切分每一条数据
    const lines = buffer.split('\n');
    // 最后一行大概率是半截,塞回buffer等下一轮
    buffer = lines.pop() || '';

    for (const line of lines) {
      const trimmed = line.trim();
      // 空行或者不是data:开头的都跳过
      if (!trimmed || !trimmed.startsWith('data:')) continue;
      const jsonStr = trimmed.slice(5).trim();
      // 结束标记,直接跳过
      if (jsonStr === '[DONE]') continue;
      try {
        const parsed = JSON.parse(jsonStr);
        const delta = parsed?.choices?.[0]?.delta?.content;
        if (delta) {
          content.value += delta;
        }
      } catch (e) {
        // 解析失败就忽略,别让整个流程崩了
      }
    }
  }
}

改完再跑,中文终于正常了,字一个一个稳稳地往外蹦,那个打字机的感觉一下就出来了。说实话那一刻还是有点小成就感的,就这么几十行代码,体验提升了一大截。

顺便说下环境变量的坑

写的时候还有个小插曲,我最开始把 API Key 直接写在代码里,后来觉得不妥,想放到环境变量里。我建了个.env.local文件,写了个DEEPSEEK_API_KEY=xxx,结果代码里读出来全是 undefined。我又懵了,为啥?查了 vite 的文档才知道,vite 默认不会把所有环境变量都暴露给前端,怕你把敏感信息不小心漏出去。只有以VITE_开头的变量,才会被挂到import.meta.env上。

改成VITE_DEEPSEEK_API_KEY=xxx,立马就好使了。

注意一下前端直接放 API 密钥是不安全的,打开浏览器控制台随便就能扒到。自己写 demo、本地跑没问题,真要上线给别人用,一定要走后端代理,把密钥藏在服务端,别直接暴露在前端。

往深扒一层:这玩意儿到底是什么原理

跑通之后我又好奇,这到底是靠什么技术实现的?是 WebSocket 吗?后来我抓包看了下,就是普通的 HTTP POST 请求,不是 WebSocket。说白了就是服务端收到请求后,不调用结束响应的方法,一直保持连接打开,生成一点数据就往响应流里写一点,直到全部生成完再关闭连接。这种方式叫 SSE(Server-Sent Events),本来是 HTML5 的一个老标准了,以前用来做服务端推送消息的,现在刚好被大模型拿来做流式输出,意外地合适。

它和 WebSocket 的区别也很简单:WebSocket 是双向的,两边都能发消息;SSE 是单向的,只有服务端往客户端推。大模型这个场景刚好就是客户端发一次请求,服务端源源不断推结果,用 SSE 刚合适,比 WebSocket 轻量多了。

而且你仔细想,大模型的推理过程本来就是流式的 ------ 它不可能一下子算出所有字,就是一个 token 一个 token 往后生成的。所以流式输出本质上只是把它生成的中间过程实时暴露出来了而已,并没有加快生成速度,只是让用户不用干等。但就是这么一点体验优化,感知强到离谱。同样是 10 秒生成完,一次性等 10 秒和边看边等 10 秒,用户的感受完全不一样。

我踩过的坑,给你们避避

整个折腾下来,踩了好几个不大不小的坑,我都列出来,你们要是做的话可以直接绕过去:

  1. 中文乱码 :TextDecoder 一定要加stream: true,一定要用 buffer 存不完整的行,别拿到数据就直接解码解析。
  2. 数据行不完整:别以为每次 read 拿到的都是完整的一行数据,网络传输什么时候分片你控制不了,留个 buffer 准没错。
  3. 环境变量前缀 :vite 里前端能访问的环境变量必须以VITE_开头,不然读出来就是 undefined。
  4. 异常捕获:解析 JSON 的时候一定要加 try/catch,万一哪条数据格式不对,别把整个流式传输搞崩了。
  5. 结束标记 :一定要处理[DONE]标记,别拿去 JSON.parse,不然会直接报错。

最后说两句

折腾完这一圈,我最大的感受有几个。第一,用户体验真的是细节堆出来的。同样的接口能力,加个流式输出,用户感知就好一大截,前端的价值很多时候就体现在这些地方。第二,很多看起来很炫的效果,底层原理其实都特别朴素。就是一个老掉牙的 SSE 协议,配上简单的行格式约定,就做出了打字机的效果,没什么黑魔法。第三,写代码别想当然。我最开始以为流式就是读一下 body 这么简单,真上手才发现乱码、buffer、格式解析一堆坑,纸上得来终觉浅,还是得亲手跑一遍。

当然也不是所有场景都适合流式输出。比如你调用接口是为了拿完整数据做后续处理,不是直接展示给用户看,那流式反而麻烦,还得自己拼完整数据,不如直接等一次性返回。

好了,大概就是这么多。你要是也在做 AI 相关的前端页面,或者也踩过类似的坑,欢迎在评论区聊聊,我也想看看你们的写法。

相关推荐
rockey6271 小时前
基于AScript的JavaScript脚本语言发布啦
javascript·c#·.net·js·script
SoaringHeart1 小时前
Flutter最佳实践:键盘辅助视图输入框天添加图片附件
前端·flutter
kyriewen2 小时前
我看完这篇安全论文——AI推荐的npm包,92%是编出来的
前端·javascript·ai编程
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:Canvas 绘制进阶-路径、渐变与混合模式
android·javascript·flutter·华为·harmonyos
小心我捶你啊3 小时前
数据采集和Web解锁不是一回事,从用途到规则区分
前端·爬虫·网络协议
hunterandroid3 小时前
[鸿蒙从零到一] ArkUI 基础组件实战:Text、Image、Button 与 TextInput
前端
fsssb4 小时前
Chromium 源码学习笔记(五):一次点击,在进入 JS 之前先经历了什么?
前端
hunterandroid4 小时前
WorkManager 可靠性实战:唯一任务、重试与幂等设计
android·前端