上周我想搭个极简的大模型测试页,方便自己调 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 秒,用户的感受完全不一样。
我踩过的坑,给你们避避
整个折腾下来,踩了好几个不大不小的坑,我都列出来,你们要是做的话可以直接绕过去:
- 中文乱码 :TextDecoder 一定要加
stream: true,一定要用 buffer 存不完整的行,别拿到数据就直接解码解析。 - 数据行不完整:别以为每次 read 拿到的都是完整的一行数据,网络传输什么时候分片你控制不了,留个 buffer 准没错。
- 环境变量前缀 :vite 里前端能访问的环境变量必须以
VITE_开头,不然读出来就是 undefined。 - 异常捕获:解析 JSON 的时候一定要加 try/catch,万一哪条数据格式不对,别把整个流式传输搞崩了。
- 结束标记 :一定要处理
[DONE]标记,别拿去 JSON.parse,不然会直接报错。
最后说两句
折腾完这一圈,我最大的感受有几个。第一,用户体验真的是细节堆出来的。同样的接口能力,加个流式输出,用户感知就好一大截,前端的价值很多时候就体现在这些地方。第二,很多看起来很炫的效果,底层原理其实都特别朴素。就是一个老掉牙的 SSE 协议,配上简单的行格式约定,就做出了打字机的效果,没什么黑魔法。第三,写代码别想当然。我最开始以为流式就是读一下 body 这么简单,真上手才发现乱码、buffer、格式解析一堆坑,纸上得来终觉浅,还是得亲手跑一遍。
当然也不是所有场景都适合流式输出。比如你调用接口是为了拿完整数据做后续处理,不是直接展示给用户看,那流式反而麻烦,还得自己拼完整数据,不如直接等一次性返回。
好了,大概就是这么多。你要是也在做 AI 相关的前端页面,或者也踩过类似的坑,欢迎在评论区聊聊,我也想看看你们的写法。