打字机是怎么炼成的:Chat 流式输出深度解析

打字机是怎么炼成的: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 处理
};

handleSubmituseChatbot() 返回的,而 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 帮我们做了三件事:

  1. messages 数组里塞一条 { role: "user", content: "你好" },用户的消息立刻显示出来
  2. 发一个 POST 请求到 /api/ai/chat,body 里带着完整的 messages 数组
  3. 准备好接收一个流式响应------不是等响应全部到达再处理,而是边收边更新

用户看到自己的消息出现在对话框里,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 BDA0------直接解码会出乱码。

解法是 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,解码、拼行、解析类型、回调 onTextPartAI 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,他是真的走通了整条链路。"

相关推荐
前端若水1 小时前
过渡(transition)高级:贝塞尔曲线、硬件加速
前端·css·css3
Lee川1 小时前
Token 无感刷新与 Logout:前端安全会话管理实战
前端·后端·react.js
不会敲代码11 小时前
我写了一个 HTML 文件,把 JS 事件循环彻底搞懂了
前端·javascript·面试
写不来代码的草莓熊1 小时前
SVG 图标插件误读 PNG 图片 + Vite 重启缓存失效重新生成 + 浏览器严格渲染
前端
燐妤1 小时前
前端HTML编程3:初识CSS
前端·html5
UXbot1 小时前
独立设计师UI设计工具推荐(2026):支持AI原型生成与代码导出的5款工具全面评价
前端·人工智能·低代码·ui·交互·产品经理·web app
.NET修仙日记2 小时前
2026 .NET 面试八股文:高频题 + 答案 + 原理(面试加分技巧)
面试·职场和发展·.net·.net core·微软技术
anOnion2 小时前
构建无障碍组件之Table Pattern
前端·html·交互设计
mfxcyh2 小时前
如何把对象数据转化为数组
java·服务器·前端