打字机是怎么炼成的:Chat 流式输出深度解析
面试官看了一眼你的项目:"AI 对话为什么能像打字机一样逐字输出?"
这个问题像剥洋葱------每剥一层都有追问。你说用了 SSE,他问为什么不用 WebSocket。你说 HTTP 分块传输,他问半个中文字被截断了怎么办。你说状态管理,他问 LLM 每秒出 50 个 token,React 跟得上吗。
要回答好这个问题,最好的方式就是沿着代码的执行路径走一遍。数据从用户敲下回车到屏幕上跳出第一个字,到底经过了哪些代码?每一行代码解决了什么问题?
第一站:用户按下发送按钮
打开 Chat.tsx,用户输入文字后点击"发送"按钮。按钮的 type="submit" 触发表单的 onSubmit 事件:
tsx
// Chat.tsx
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
handleSubmit(e); // 把事件交给 AI SDK 处理
};
handleSubmit 是 useChatbot() 返回的,而 useChatbot 来自 @ai-sdk/react:
typescript
// hooks/useChatBot.ts
export const useChatbot = () => {
return useChat({
api: '/api/ai/chat',
onError: (err) => console.log("Chat Error: ", err)
})
}
就这几行代码,useChat 帮我们做了三件事:
- 往
messages数组里塞一条{ role: "user", content: "你好" },用户的消息立刻显示出来 - 发一个 POST 请求到
/api/ai/chat,body 里带着完整的messages数组 - 准备好接收一个流式响应------不是等响应全部到达再处理,而是边收边更新
用户看到自己的消息出现在对话框里,AI 那边还在"想",页面显示三个跳动的点。接下来,请求穿过 Vite 的 mock 中间件,到达了我们写的 mock/chat.js。
第二站:mock 层的翻译工作
mock/chat.js 是整个流式输出链条里最关键的环节。它扮演的角色是翻译 + 中继------左手从 DeepSeek 拿数据,右手转成前端能识别的格式发出去。
先看它是怎么把请求转发给 DeepSeek 的:
javascript
// mock/chat.js
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: messages,
stream: true // ← 关键:告诉 DeepSeek 我要流式输出
})
});
stream: true 这个参数决定了 DeepSeek 的返回方式。不加它,DeepSeek 会返回一个完整的 JSON 对象,里面 choices[0].message.content 是全部回答。加了它,DeepSeek 会把回答拆成碎片,用 SSE 格式一片一片推过来。
这里有一个我们实际踩过的坑:最初代码写的是 streaming: true,错了。DeepSeek 兼容的是 OpenAI 的参数名,应该叫 stream。就这一个字母的差别,DeepSeek 根本不理会,照样返回完整 JSON,导致前端等了好几秒才一次性显示全部文字,完全没有打字机效果。
拿到 DeepSeek 的流式响应后,下一步是实时翻译。
DeepSeek 返回的是 SSE 格式,每一片数据长这样:
css
data: {"id":"xxx","choices":[{"delta":{"content":"你"}}]}
data: {"id":"xxx","choices":[{"delta":{"content":"好"}}]}
data: [DONE]
前端用的 @ai-sdk/react 不认识 SSE,它有自己的"方言"------Vercel AI SDK Data Stream 协议。格式是这样的:
arduino
0:"你"
0:"好"
0: 是类型标签,表示"这是一段普通文本"。AI SDK 还支持 2: 表示工具调用、3: 表示错误、d: 表示结束等。
所以 mock 要做的就是把 SSE 里的 data.choices[0].delta.content 取出来,包上一层 0: 的外壳:
javascript
// mock/chat.js --- 核心转换
const data = JSON.parse(line.slice(6)); // 去掉 "data: " 前缀
const content = data.choices[0]?.delta?.content; // 取出这次产生的字
if (content) {
res.write(`0:${JSON.stringify(content)}\n`); // 翻译成 AI SDK 方言
}
而这一切不是等 DeepSeek 全部输出完才做的------是收到一行就翻译一行、立刻 res.write() 发出去。整条管道没有一处"蓄水",所以能做到流式。
第三站:前端的流式消费
数据被逐片推送到浏览器后,AI SDK 的 processDataStream 函数开始工作。打开 @ai-sdk/ui-utils 的源码,核心逻辑简化后是这样的:
javascript
// @ai-sdk/ui-utils --- processDataStream
async function processDataStream({ stream, onTextPart }) {
const reader = stream.getReader(); // 拿到流的"遥控器"
const decoder = new TextDecoder(); // 字节 → 文字的翻译器
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() || ''; // 最后一段留着,下次拼
for (const line of lines) {
const { type, value } = parseDataStreamPart(line);
// line = '0:"你"' → type = "text", value = "你"
if (type === 'text') {
onTextPart(value); // 回调:把新字追加到消息里
}
}
}
}
onTextPart(value) 被调用时,AI SDK 把新收到的字拼接到 assistant 消息的 content 里,然后调一次 React 的 setState。React 发现状态变了,重新渲染 Chat.tsx 里的 messages.map(),用户就看到屏幕上多了一个字。
整个过程从用户敲下回车到屏幕上出现第一个字,时间极短------因为每个环节都在"流",没有人在等。
四、两个必须处理的边界情况
4.1 一行数据被切成两半
TCP 是流式协议,它不关心你的数据边界。假设 DeepSeek 推送了这样一行:
css
data: {"choices":[{"delta":{"content":"你好"}}]}\n
但网络层可能切成两块发送------第一块 data: {"delta":{"content":"你 到了,第二块 好"}}]}\n 要过几毫秒才到。如果你拿到第一块就急着解析,JSON 格式是坏的,直接报错。
解决方式藏在这一行里:
javascript
buffer = lines.pop() || ''; // 最后一段放回去,不处理
split('\n') 把缓冲区按换行切成数组,最后一段一定是没有换行结尾的不完整内容 。pop() 把它拿出来,塞回 buffer 里,等下一次循环收到新数据拼起来再处理。只处理以 \n 结尾的完整行,不完整的行永远留在缓冲区里等下一次拼接。
4.2 一个中文字被切成两半
比 JSON 半行更隐蔽的问题:UTF-8 编码下,一个中文字占 3 个字节。如果 TCP 分包恰好切在了一个字的中间------比如"你"的 E4 BD A0 被切成了 E4 BD 和 A0------直接解码会出乱码。
解法是 TextDecoder.decode() 的第二个参数:
javascript
decoder.decode(value, { stream: true })
{ stream: true } 告诉解码器:"后面还有数据,别急着解码,把不完整的字节序列暂存起来。"TextDecoder 内部维护了一个小缓冲区,专门存不完整的多字节序列。下次 decode 被调用时,它先把上次存的和新来的拼在一起再解码。
这两个边界处理看起来是极小的细节,但正是它们决定了你的流式输出是"偶尔乱码"还是"稳定正确"。
五、每次收到一个字就渲染?那页面早卡死了
LLM 生成 token 的速度大约是每秒 30 到 50 个。如果每收到一个字就调一次 setState 触发 React 渲染,页面每秒要重绘几十次。
打开 AI SDK 的 triggerRequest 源码,它用了一个简单粗暴但极其有效的优化:
javascript
// @ai-sdk/react --- triggerRequest 内部
const throttledMutate = throttle(mutate, throttleWaitMs);
// 收到新 token 时:
throttledMutate([...chatMessages, updatedMessage], false);
throttle 像一个节拍器------不管上游来得多快,下游的 mutate(也就是 React 的状态更新)只在固定时间窗口内执行一次。这个窗口通常非常短(几十毫秒),用户肉眼完全感觉不到延迟,但 React 实际渲染频率从每秒 50 次降到了五六次。
前端性能优化的本质经常就是这样------不是让单次操作更快,而是减少操作的次数。
六、为什么选这套技术方案?
SSE vs WebSocket
WebSocket 是对讲机 ------双方都能随时说话。SSE 是收音机------电台在播,你听就行。
AI 对话是典型的单向推送:你问一句,它给一长段回答。数据只从服务器流向浏览器。这让 SSE 成了天然的最佳选择:
- 不需要 WebSocket 的 HTTP Upgrade 握手
- 浏览器原生支持自动重连
- 与所有 HTTP 代理 100% 兼容
- 三行响应头就完成配置
mock 里跟流式相关的配置只有这几行:
javascript
res.setHeader('Content-Type', 'text/plain;charset=utf-8'); // 内容类型
res.setHeader('Transfer-Encoding', 'chunked'); // 分块传输
res.setHeader('x-vercel-ai-data-stream', 'v1'); // AI SDK 协议标识
Transfer-Encoding: chunked 是最关键的一行。它告诉浏览器"响应体会分成多个数据块,每个块前面有长度标记,收到长度为 0 的块表示传输结束"。浏览器收到每个 chunk 会立刻触发 reader.read() 返回,前端就能实时更新 UI。
为什么不用原生 fetch 手写流解析?
用一个 useChat hook 就解决了消息列表管理、加载状态、错误处理、节流渲染这些问题。代价是中间需要一层格式转换(SSE → AI SDK Data Stream)。这是典型的用转换复杂度换业务代码简洁的取舍。
七、整体回顾:一条数据的完整旅途
回头看整个链路,流式输出的本质就是把"等全部做完再给"变成"做好一点就给一点":
DeepSeek 生成一个字 → 推到 SSE 流里 → mock 收到一行,立刻提取内容,翻译成 AI SDK 格式,res.write() 发出去 → 浏览器 收到 chunk,解码、拼行、解析类型、回调 onTextPart → AI SDK 把新字追加到消息对象,调 mutate 更新状态 → React 发现状态变了,重新渲染,用户看到新字出现在屏幕上。
整个链条没有一处是"等全部完成再往下走"的。这是设计流式系统最重要的原则------每一环都是即收即转,不做蓄水。
八、面试时怎么回答
面试官问"介绍下流式输出怎么做的",可以这样组织:
第一步,讲原理:大模型是自回归的,天生一个字一个字生成。流式输出不是额外加的功能,而是让大模型的本性暴露给用户看。首字延迟从等全部回答的 3 秒降到等第一个 token 的几百毫秒。
第二步,讲实现 :用 stream: true 开启 DeepSeek 流式 → mock 层把 SSE 格式实时翻译成 AI SDK 的 Data Stream 格式 → 前端 useChat hook 通过 ReadableStream 逐 chunk 消费 → 每收到一个 token 更新 React 状态 → 用户看到逐字输出。
第三步,讲细节 (区分你跟其他候选人的关键):TCP 拆包用 split('\n') + pop() 处理半行;UTF-8 截断用 TextDecoder.decode(value, { stream: true }) 处理半个汉字;React 渲染频率用 throttle 控制,防止每秒 50 次渲染。
第四步,讲技术选型:选 SSE 不选 WebSocket 因为场景是单向推送;选 AI SDK 不手写因为框架封装了状态管理、错误处理、节流渲染;选 mock 层做格式转换而不是前端直接调 DeepSeek 因为要保护 API key 不暴露给浏览器。
第五步,做总结:流式输出核心不在于"能不能流",而在于"流得对、流得稳、流得快"。对,靠拆包和编码处理;稳,靠错误态和重试;快,靠节流和零蓄水。
面试官听到这,心里想的是:"这个人不只是调了个 API,他是真的走通了整条链路。"