前言
在AI聊天、实时消息推送等场景中,流式输出是提升用户体验的核心技术之一。其核心逻辑是“边生成、边返回”,而非等待所有结果生成完毕后一次性响应,呈现出类似打字机逐字输出的效果,让前端用户能够实时看到反馈,极大降低等待焦虑。
本次学习重点围绕两种主流的流式输出实现方式展开——基于mockjs的前端模拟流式输出(适用于开发测试阶段,无需依赖真实后端)和基于nestjs的后端真实流式输出(适用于生产环境,对接真实LLM接口)。结合提供的完整代码,从技术原理、环境配置、代码拆解、前端对接、问题排查等维度,全面梳理流式输出的实现流程,帮助深入理解其底层逻辑,同时掌握实际开发中的应用技巧。
本文笔记总字数约7000字,采用Markdown格式,结构清晰、重点突出,兼顾理论与实践,适合前端、后端开发人员学习参考,尤其适合刚接触流式输出、AI接口对接的开发者快速上手。
第一章 流式输出核心原理铺垫
在深入讲解两种实现方式之前,首先梳理流式输出的核心原理、相关技术栈及核心概念,为后续代码解析奠定基础。只有理解了底层逻辑,才能更好地掌握两种实现方式的差异与关联。
1.1 流式输出的本质
流式输出的本质是“分块传输、实时渲染”,核心区别于传统的“完整生成、一次性返回”的响应模式。在AI场景中,这一特性与大语言模型(LLM)的生成逻辑高度契合:
LLM生成文本时,并非一次性生成全部内容,而是通过自回归方式,基于已生成的token序列,逐个预测下一个最可能的token(token是文本的最小单位,可理解为单个字符、词语或子词)。流式输出正是利用这一特性,将模型生成的每个token实时传输到前端,前端接收一个token就渲染一个token,从而实现“打字机”效果。
从HTTP协议层面来看,流式输出依赖「HTTP Chunked Transfer Encoding」(分块传输编码),该编码允许服务器将响应体分成多个数据块(chunk),每个数据块都有自己的大小标识,服务器发送完所有数据块后,发送一个大小为0的块表示响应结束。这种方式无需服务器提前知道响应体的总大小,完美适配LLM逐token生成的场景。
1.2 核心技术栈与工具
结合本次学习的代码内容,实现流式输出涉及的核心技术栈与工具如下,需提前掌握基础用法:
-
前端技术:React(Next.js)、@ai-sdk/react(Vercel AI SDK,封装了流式输出的前端逻辑)、shadcn UI(ScrollArea组件,优化滚动体验)、TypeScript(类型校验);
-
模拟工具:mockjs(前端Mock工具,用于模拟后端流式接口,快速验证前端逻辑);
-
后端框架:NestJS(Node.js后端框架,支持依赖注入、模块化开发,适合构建可扩展的后端服务);
-
LLM集成工具:LangChain(大语言模型集成框架,简化与DeepSeek等LLM接口的对接,提供流式输出封装);
-
协议与规范:SSE(Server-Sent Events,服务器向客户端单向推送事件流,适合流式输出场景)、HTTP分块传输编码;
-
辅助工具:dotenv(加载环境变量)、class-validator/class-transformer(NestJS中实现参数校验)。
1.3 核心流程梳理
无论采用mockjs还是nestjs实现流式输出,整体流程均可分为3个核心步骤,前后端协同工作:
-
前端发起请求:前端通过AI SDK(@ai-sdk/react)发起请求,指定流式输出模式,同时监听服务器推送的事件流;
-
服务器生成流式响应:服务器(mockjs/nestjs)接收请求后,对接LLM(mockjs模拟LLM生成逻辑,nestjs对接真实LLM),逐token生成响应内容,通过分块传输(Chunked)或SSE协议实时推送至前端;
-
前端接收并渲染:前端监听服务器推送的每个token,实时更新页面,呈现打字机效果,同时处理加载状态、滚动自动定位等交互逻辑。
后续将围绕这一核心流程,分别拆解mockjs和nestjs的具体实现细节。
第二章 mockjs实现流式输出(前端模拟)
在项目开发初期,后端服务尚未搭建完成时,我们可以使用mockjs模拟后端流式接口,快速验证前端流式输出逻辑,降低前后端联调成本。本次学习中,mockjs不仅模拟了接口响应,还模拟了LLM逐token生成、SSE推送、分块传输等核心逻辑,完全贴合真实场景。
2.1 环境准备与依赖安装
首先需要搭建前端项目(Next.js/React),并安装所需依赖,确保mockjs能够正常运行,同时支持流式输出相关特性。
2.1.1 项目基础环境
本次学习基于Next.js(React SSR框架)开发前端,Next.js自带Node.js运行环境,可直接运行mockjs(mockjs依赖Node.js环境)。若使用纯React项目,需搭配Vite等构建工具,并安装vite-plugin-mock插件启用mock功能。
项目基础环境要求:
-
Node.js ≥ 18.x(支持ES6+语法、Async/Await、Stream API);
-
npm/yarn/pnpm 包管理工具;
-
Next.js ≥ 14.x(或React ≥ 18.x + Vite)。
2.1.2 核心依赖安装
打开终端,在项目根目录执行以下命令,安装流式输出所需的核心依赖:
# 安装AI SDK(封装流式前端逻辑)
npm install @ai-sdk/react
# 安装mock工具
npm install mockjs vite-plugin-mock --save-dev
# 安装UI组件(shadcn UI,用于优化滚动体验)
npm install @/components/ui/scroll-area @/components/ui/input @/components/ui/button
# 安装环境变量加载工具
npm install dotenv --save-dev
# 安装TypeScript类型依赖(可选,若使用TS)
npm install @types/mockjs @types/react-form-event --save-dev
2.1.3 mockjs配置(Vite/Next.js)
若使用Vite构建项目,需在vite.config.js中配置vite-plugin-mock,指定mock文件目录,启用本地mock服务:
import { defineConfig } from 'vite';
import { viteMockServe } from 'vite-plugin-mock';
export default defineConfig({
plugins: [
viteMockServe({
mockPath: 'mock', // mock文件所在目录(如项目根目录下的mock文件夹)
localEnabled: true, // 开发环境启用mock
prodEnabled: false, // 生产环境关闭mock
supportTs: false, // 若使用TS编写mock文件,设为true
})
]
});
若使用Next.js项目,可直接在项目根目录创建mock文件夹,编写mock配置文件,Next.js会自动识别(需确保mock文件导出正确的接口配置)。
2.2 mockjs流式接口完整配置解析
本次学习提供的mockjs配置文件,实现了一个POST请求接口「/api/ai/chat」,完全模拟了真实后端流式输出的逻辑,包括请求体解析、LLM token生成、分块传输、SSE响应头配置等。以下是完整代码解析,逐行拆解核心逻辑:
2.2.1 基础配置与环境变量加载
// 加载环境变量(mockjs运行在Node环境,需通过dotenv加载.env文件中的环境变量)
import { config } from "dotenv";
config(); // 执行后,process.env可访问.env文件中的变量(如DEEPSEEK_API_KEY)
// 导出mock接口配置(数组形式,可配置多个接口)
export default [
{
url: '/api/ai/chat', // 接口路径(与前端请求路径一致)
method: 'POST', // 请求方法(与前端请求方法一致,必须为POST,因需传递对话历史)
// rawResponse:自定义原始HTTP响应(核心!流式输出必须使用rawResponse,而非response)
// 区别:response返回结构化JSON,rawResponse可自定义HTTP响应头、响应体,支持分块传输
rawResponse: async (req, res) => {
// 1. 解析前端传递的请求体(Node.js原生方式,因mockjs运行在Node环境)
let body = '';
// 监听请求体数据事件(req是Node.js的IncomingMessage对象,数据分块传输)
req.on('data',(chunk) => {
// chunk是二进制Buffer,转换为字符串拼接,最终得到完整请求体
body += chunk
})
// 监听请求体接收完毕事件(所有数据块均已接收)
req.on('end', async () => {
try {
// 解析请求体为JSON对象(前端传递的是对话历史messages)
const {messages} = JSON.parse(body);
// 2. 配置流式响应头(核心!告诉浏览器这是流式响应,采用分块传输)
res.setHeader('Content-Type','text/plain;charset=utf-8'); // 响应类型为纯文本,解决中文乱码
res.setHeader('Transfer-Encoding','chunked'); // 启用分块传输编码(关键)
res.setHeader('x-vercel-ai-data-stream','v1'); // Vercel AI SDK要求的响应头,用于识别流式数据
// 3. 模拟LLM流式生成token(核心逻辑,模拟真实LLM逐token生成)
// 对接真实DeepSeek接口(此处仅模拟请求,实际mock中可省略,直接生成模拟token)
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,
stream: true, // 开启LLM流式输出
}),
})
// 校验LLM响应体是否存在
if(!response.body) throw new Error('no response body');
// 4. 读取LLM流式响应,解析token并推送至前端
// 获取响应体的读取器(ReadableStreamDefaultReader),用于逐块读取LLM输出
const reader = response.body.getReader();
// 创建解码器(TextDecoder),将LLM返回的二进制Buffer解码为字符串
const decoder = new TextDecoder('utf-8');
// 循环读取LLM流式输出(直到读取完毕)
while(true){
// 读取一个数据块(done:是否读取完毕,value:当前数据块的Buffer)
const { done, value } = await reader.read();
if(done) break; // 若读取完毕,退出循环
// 将当前数据块解码为字符串(LLM返回的是JSON格式的token流)
const chunk = decoder.decode(value);
console.log(chunk,'///////'); // 打印解析后的token块,用于调试
// 解析token流(LLM返回的格式为多行字符串,每行是一个token相关的JSON)
const lines = chunk.split('\n'); // 按行分割数据块
for (let line of lines) { // 循环处理每一行
// 过滤有效token行(LLM流式输出中,有效token行以"data: "开头,且不是结束信号)
if(line.startsWith('data: ')&& line !== 'data: [DONE]'){
try{
// 去掉"data: "前缀,解析为JSON对象(获取token内容)
const data = JSON.parse(line.slice(6));
// 提取token内容(choices[0].delta.content,delta表示增量token)
const content = data.choices[0]?.delta?.content || '';
if(content) {
// 5. 推送token至前端(核心!采用AI SDK要求的格式)
// res.write():向响应流写入数据块(分块传输)
// 格式:0:${JSON.stringify(content)}\n(AI SDK约定的格式,用于识别token)
res.write(`0:${JSON.stringify(content)}\n`);
}
} catch (error){
// 捕获JSON解析错误,避免流式输出中断
console.error('Error parsing JSON:', error);
}
}
}
}
// 6. 结束响应(所有token推送完毕后,关闭响应流)
res.end();
} catch (error){
// 捕获整体错误(如请求体解析失败、LLM请求失败),避免服务崩溃
console.error('Mock stream error:', error);
}
})
}
}
]
2.2.2 核心逻辑拆解(重点)
mockjs流式接口的核心在于「rawResponse」配置和「流式token处理逻辑」,以下是关键知识点拆解,结合代码逐点说明:
(1)rawResponse的作用
mockjs提供两种响应方式:response和rawResponse:
-
response:用于返回结构化JSON响应,适用于普通接口(非流式),mockjs会自动封装响应头、状态码;
-
rawResponse:用于自定义原始HTTP响应,可直接操作Node.js的req(请求对象)和res(响应对象),支持设置自定义响应头、分块传输、流式输出,是实现流式响应的核心。
流式输出必须使用rawResponse,因为需要手动配置分块传输响应头、逐块写入响应体,这些操作无法通过response实现。
(2)请求体解析逻辑
前端发起POST请求时,会将对话历史(messages)放在请求体中,以JSON格式传递。mockjs运行在Node.js环境中,需通过Node.js原生方式解析请求体:
-
req.on('data', chunk => { ... }):监听请求体的data事件,每当有数据块(chunk,二进制Buffer)传输过来,就拼接成字符串,最终得到完整的请求体;
-
req.on('end', async () => { ... }):监听请求体的end事件,当所有数据块均已接收完毕后,解析请求体为JSON对象,获取对话历史messages。
注意:若请求体解析失败(如JSON格式错误),会触发catch异常,避免mock服务崩溃。
(3)流式响应头配置(关键)
响应头的配置直接决定了浏览器是否能正确识别流式响应,本次配置了3个核心响应头:
-
Content-Type: text/plain;charset=utf-8:设置响应类型为纯文本,同时指定字符编码为utf-8,解决中文乱码问题; -
Transfer-Encoding: chunked:启用HTTP分块传输编码,告诉浏览器响应体将分多个数据块传输,无需提前知道总大小; -
x-vercel-ai-data-stream: v1:Vercel AI SDK(@ai-sdk/react)约定的响应头,用于识别该响应是流式数据,SDK会自动监听并解析该响应。
补充:若采用SSE协议实现流式输出,还需添加以下响应头(本次代码中未使用,但可参考):
res.setHeader('Content-Type', 'text/event-stream'); // SSE协议专用响应类型
res.setHeader('Cache-Control', 'no-cache'); // 禁止缓存,确保每次请求都获取最新数据
res.setHeader('Connection', 'keep-alive'); // 保持HTTP连接,避免频繁建立/关闭连接
(4)LLM流式响应模拟与解析
本次mock代码中,模拟了对接真实DeepSeek LLM接口的逻辑,同时实现了LLM流式输出的读取与解析:
-
发起LLM请求:通过fetch请求DeepSeek的聊天接口,开启stream: true,告诉LLM返回流式响应;
-
获取读取器:response.body.getReader() 获取响应体的读取器,用于逐块读取LLM输出的二进制数据;
-
解码数据块:通过TextDecoder将二进制Buffer解码为字符串,得到LLM返回的token流;
-
解析token流:LLM流式输出的格式为多行字符串,每行以"data: "开头,格式如下:
data: {"id":"xxx","object":"chat.completion.chunk","created":1690000000,"model":"deepseek-chat","choices":[{"delta":{"content":"你"},"index":0,"finish_reason":null}]}
data: {"id":"xxx","object":"chat.completion.chunk","created":1690000001,"model":"deepseek-chat","choices":[{"delta":{"content":"好"},"index":0,"finish_reason":null}]}
data: [DONE]
代码中通过split('\n')按行分割,过滤掉无效行和结束信号(data: [DONE]),解析每行的JSON对象,提取delta.content(即单个token);
- 推送token:若解析到有效token,通过res.write()将token写入响应流,推送至前端,格式需符合AI SDK约定(0:${JSON.stringify(content)}\n)。
注意:循环读取直到done为true(读取完毕),然后调用res.end()结束响应流,避免前端一直等待响应。
(5)错误处理逻辑
代码中添加了两层错误处理,确保mock服务稳定运行:
-
内层try-catch:捕获请求体解析、LLM请求、JSON解析过程中的异常,避免单个请求失败导致整个mock服务崩溃;
-
外层try-catch:捕获整体响应过程中的异常,打印错误信息,方便调试。
2.3 前端对接mockjs流式接口(完整实现)
mockjs接口搭建完成后,前端需要通过@ai-sdk/react封装的钩子函数,发起流式请求,接收token并实时渲染,同时实现聊天界面的交互逻辑。本次学习提供了完整的前端代码,包括Chat组件、useChatBot钩子,以下是详细解析。
2.3.1 前端钩子封装(useChatBot.js)
为了复用流式聊天逻辑,封装了useChatBot钩子,基于@ai-sdk/react的useChat钩子,简化前端调用:
// 导入AI SDK的useChat钩子(核心,封装了流式请求、token接收逻辑)
import { useChat } from '@ai-sdk/react';
// 导出自定义钩子,复用聊天逻辑
export const useChatBot = () => {
return useChat({
api: '/api/ai/chat', // 对接mockjs的流式接口路径(与mock配置的url一致)
// api: 'http://localhost:3000/api/ai/chat', // 后续对接nestjs后端时,替换为后端接口路径
onError: (error) => {
// 错误处理:打印聊天过程中的异常(如接口请求失败、token解析失败)
console.log(error,'chatbot error');
}
})
}
核心解析:
-
useChat钩子:@ai-sdk/react封装的核心钩子,内部实现了流式请求的发起、SSE事件监听、token接收、消息管理等逻辑,无需手动编写复杂的请求和监听代码;
-
api参数:指定流式接口的路径,此处对接mockjs的「/api/ai/chat」接口;
-
onError回调:捕获聊天过程中的所有异常,方便调试和用户提示。
useChat钩子返回的核心参数(后续组件中会用到):
-
messages:对话历史数组,每个元素包含role(角色:user/assistant)、content(内容);
-
input:输入框的值,由AI SDK自动管理;
-
handleInputChange:输入框变化事件处理函数,自动更新input值;
-
handleSubmit:提交事件处理函数,发起流式请求;
-
isLoading:加载状态(true表示正在接收token,false表示接收完毕)。
2.3.2 聊天组件实现(Chat.jsx)
Chat组件是前端流式输出的核心展示组件,实现了聊天界面渲染、输入框交互、流式加载状态、滚动优化等功能,完整代码解析如下:
(1)组件导入与初始化
// 导入所需组件和钩子
import Header from '@/components/Header'; // 自定义头部组件
import { useChatBot } from '@/hooks/useChatBot'; // 导入自定义聊天钩子
import { ScrollArea } from '@/components/ui/scroll-area'; // shadcn UI的滚动组件
import { Input } from '@/components/ui/input'; // 输入框组件
import { Button } from '@/components/ui/button'; // 按钮组件
// 导出Chat组件(函数式组件,TS语法)
export default function Chat() {
// 从useChatBot钩子中获取核心参数
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
} = useChatBot();
// 表单提交处理函数(重写,添加输入校验)
const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); // 阻止表单默认提交行为(避免页面刷新)
if (!input.trim()) return; // 若输入为空,不发起请求
handleSubmit(e); // 调用AI SDK的提交函数,发起流式请求
}
// 组件渲染逻辑(后续拆解)
return (/* ... */)
}
(2)表单提交逻辑解析
表单提交逻辑是发起流式请求的入口,核心优化点:
-
e.preventDefault():阻止表单默认提交行为,避免页面刷新,确保流式请求正常进行;
-
输入校验:若输入框内容为空(trim()后),不发起请求,避免无效请求;
-
调用handleSubmit:触发AI SDK的提交函数,发起POST请求到mockjs接口,同时开始监听token流。
(3)组件渲染结构(JSX)
return (
{/* 外层容器:弹性布局,占满整个屏幕,居中显示,设置内边距 */}
{/* 头部组件:显示标题,添加返回按钮 */}
<Header title='DeepSeek Chat' showBackButton={true} />
{/* 滚动区域:优化聊天记录的滚动体验(替代原生滚动条) */}
<ScrollArea className='flex-1 border rounded-lg p-4 mb-4 bg-background'>
{/* 对话历史为空时,显示提示信息 */}
{
messages.length === 0 ? (
Start a conversation with DeepSeek...
): (
{/* 对话历史不为空时,渲染所有消息 */}
{
messages.map((message, index) => (
// 渲染单条消息,根据角色(user/assistant)设置对齐方式
<div
key={用message.id(若有) */
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={4 py-2 ${
message.role === 'user'
? 'bg-primary text-primary-foreground' // 用户消息:主题色背景
: 'bg-muted' // 助手消息:灰色背景
}`}
>
{message.content} {/* 消息内容(实时渲染,流式输出时逐字更新) */}
))
}
{/* 加载状态:正在接收token时,显示脉冲动画(...) */}
{ isLoading && (
...
)}
)
}
</ScrollArea>
{/* 输入框表单:发起聊天请求 */}
<form onSubmit={<Input
value={input}
onChange={handleInputChange}
placeholder='Type your message...'
disabled={isLoading} // 加载中禁用输入框,避免重复提交
className='flex-1'
/>
<Button type='submit' disabled={isLoading || !input.trim()}>
Send
</Button>
)
(4)核心渲染逻辑拆解
- 滚动区域优化:
使用shadcn UI的ScrollArea组件替代原生滚动条,原因是原生滚动条样式丑陋、体验较差,ScrollArea组件自带优化后的样式,同时支持自动滚动到底部(后续可添加),提升用户体验。组件的className设置为flex-1,确保滚动区域占满剩余屏幕空间。
- 对话历史渲染:
-
空状态提示:当messages数组为空时,显示提示文本“Start a conversation with DeepSeek...”,引导用户发起对话;
-
消息列表渲染:通过map循环渲染每条消息,根据message.role设置对齐方式(用户消息右对齐,助手消息左对齐);
-
消息样式:设置最大宽度为80%,避免消息过长;根据角色设置不同的背景色,区分用户和助手消息;
-
key的使用:用index作为key(兜底方案),实际开发中优先使用message.id(唯一标识),避免列表渲染异常。
- 流式加载状态:
当isLoading为true(正在接收token)时,显示一个灰色背景的提示框,里面有“...”脉冲动画,告诉用户“正在思考中”,提升交互体验。
- 输入框与提交按钮:
-
输入框:value绑定input(AI SDK管理),onChange绑定handleInputChange(自动更新input值);加载中(isLoading)禁用输入框,避免重复提交;
-
提交按钮:type设为submit,绑定表单提交事件;禁用条件:加载中(isLoading)或输入为空(!input.trim()),避免无效提交。
2.3.3 流式输出效果验证与调试
完成mockjs接口和前端组件开发后,需要验证流式输出效果,同时进行调试,确保功能正常。
(1)效果验证步骤
-
启动开发服务器:npm run dev(Next.js/Vite),确保mock服务正常启用;
-
访问Chat组件页面:在浏览器中打开http://localhost:3000(默认端口),进入聊天页面;
-
发起对话:在输入框中输入问题(如“什么是流式输出?”),点击Send按钮;
-
验证流式效果:观察助手消息,应呈现“打字机”效果,逐字显示响应内容,同时加载状态(...)消失;
-
多轮对话验证:继续输入问题,验证对话历史是否正常保留,流式输出是否持续有效。
(2)调试技巧
- 控制台调试:
-
前端控制台:打印messages、input、isLoading等参数,查看是否正常更新;
-
后端(mock)控制台:打印chunk、content等参数,查看LLM token是否正常解析、推送。
- 网络面板调试:
打开浏览器F12→Network,找到「/api/ai/chat」请求,查看Response/Preview面板,流式输出时会显示“正在加载”,同时可以看到逐块传输的token数据,确认响应头是否正确配置(Transfer-Encoding: chunked)。
- 常见问题排查:
-
无流式效果,一次性返回所有内容:检查mock配置中是否启用rawResponse,是否设置了Transfer-Encoding: chunked响应头;
-
加载状态一直显示,无响应内容:检查mock接口路径与前端api参数是否一致,请求方法是否为POST,控制台是否有报错;
-
中文乱码:检查响应头中是否设置了charset=utf-8;
-
mock接口未命中:检查mock配置文件路径是否正确,vite-plugin-mock是否启用,重启开发服务器尝试。
2.4 mockjs流式输出的优缺点与适用场景
通过mockjs实现流式输出,主要用于开发测试阶段,有其明确的优缺点和适用场景,需合理使用。
2.4.1 优点
-
开发效率高:无需搭建后端服务,仅通过前端配置即可模拟流式接口,快速验证前端逻辑;
-
调试方便:可自由模拟LLM的token生成速度、响应内容,便于调试前端的加载状态、渲染逻辑;
-
降低联调成本:前端可独立开发、测试,无需等待后端接口开发完成;
-
贴近真实场景:模拟了LLM流式生成、分块传输、SSE推送等核心逻辑,与真实后端接口的交互方式一致。
2.4.2 缺点
-
仅适用于开发测试:无法对接真实LLM接口,不能用于生产环境;
-
功能有限:无法实现复杂的后端业务逻辑(如对话历史持久化、权限校验、参数校验);
-
存在环境差异:mockjs运行在Node.js开发环境,与生产环境的后端服务可能存在差异,联调时仍需测试。
2.4.3 适用场景
-
项目开发初期,后端服务尚未搭建完成,前端需独立开发流式输出功能;
-
前端调试:调试前端的流式渲染、加载状态、交互逻辑等,无需依赖真实后端;
-
演示原型:快速搭建流式聊天原型,展示产品效果。
第三章 nestjs实现流式输出(后端真实实现)
当项目进入测试或生产阶段,需要对接真实的LLM接口(如DeepSeek),此时需使用后端框架搭建流式接口。本次学习采用NestJS框架,结合LangChain集成LLM,实现生产级别的流式输出,同时添加参数校验、错误处理、模块化开发等特性,确保服务的稳定性和可扩展性。
3.1 环境准备与项目搭建
首先需要搭建NestJS项目,安装所需依赖,配置环境变量,确保能够正常对接LLM接口(DeepSeek)和前端请求。
3.1.1 项目基础环境
NestJS项目环境要求:
-
Node.js ≥ 18.x(支持ES6+、Async/Await、Stream API);
-
npm/yarn/pnpm 包管理工具;
-
TypeScript ≥ 5.x(NestJS默认使用TS开发,提供类型安全);
-
NestJS CLI ≥ 10.x(用于创建项目、生成模块/控制器/服务)。
3.1.2 安装NestJS CLI并创建项目
# 全局安装NestJS CLI
npm install -g @nestjs/cli
# 创建NestJS项目(项目名:ai-stream-demo)
nest new ai-stream-demo
# 进入项目目录
cd ai-stream-demo
3.1.3 安装核心依赖
执行以下命令,安装流式输出、LLM集成、参数校验所需的核心依赖:
# 安装LLM集成依赖(LangChain + DeepSeek)
npm install @langchain/deepseek langchain
# 安装参数校验依赖
npm install class-validator class-transformer
# 安装环境变量加载依赖
npm install dotenv
# 安装类型依赖(可选)
npm install @types/node --save-dev
3.1.4 配置环境变量
在项目根目录创建.env文件,配置DeepSeek API密钥和基础URL(从DeepSeek官网申请),避免硬编码:
# .env文件
DEEPSEEK_API_KEY=your_deepseek_api_key
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
在NestJS的main.ts中加载环境变量,并配置全局参数校验(补充完整):
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { config } from 'dotenv';
// 导入参数校验相关依赖
import { ValidationPipe } from '@nestjs/common';
// 加载环境变量
config();
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 配置全局参数校验(核心!启用DTO校验)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 自动过滤请求体中未在DTO中定义的字段
forbidNonWhitelisted: true, // 若请求体包含未定义字段,直接抛出异常
transform: true, // 自动将请求体转换为DTO类的实例(支持嵌套对象转换)
}),
);
// 解决跨域问题(前端与后端端口不同时需配置)
app.enableCors();
await app.listen(3000); // 后端服务运行在3000端口
console.log(`NestJS server running on http://localhost:3000`);
}
bootstrap();
3.2 NestJS项目结构与模块化设计
NestJS的核心思想是模块化开发,本次学习围绕AI流式聊天功能,创建ai模块,包含以下核心文件:
-
ai.controller.ts:控制器,处理前端请求,定义路由,返回流式响应;
-
ai.service.ts:服务,封装核心业务逻辑(LLM集成、流式生成、token处理);
-
dto/chat.dto.ts:数据传输对象,定义前端请求参数的格式和校验规则;
-
ai.module.ts:模块,整合控制器、服务,对外暴露功能。
使用NestJS CLI生成ai模块、控制器、服务:
# 生成ai模块
nest g module ai
# 生成ai控制器
nest g controller ai
# 生成ai服务
nest g service ai
生成后,项目核心结构如下(补充完整):
src/
├── ai/ # ai模块(流式输出核心模块)
│ ├── dto/ # 数据传输对象目录
│ │ └── chat.dto.ts # 聊天请求参数校验DTO
│ ├── ai.controller.ts # 控制器(路由定义)
│ ├── ai.service.ts # 服务(业务逻辑封装)
│ └── ai.module.ts # ai模块配置
├── app.module.ts # 根模块
├── main.ts # 入口文件(服务启动、全局配置)
└── .env # 环境变量配置
配置ai.module.ts(补充完整,注册控制器和服务):
// src/ai/ai.module.ts
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
// 注册控制器(处理请求)
controllers: [AiController],
// 注册服务(封装业务逻辑,可通过依赖注入使用)
providers: [AiService],
})
export class AiModule {}
将AiModule导入根模块app.module.ts(补充完整):
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AiModule } from './ai/ai.module';
@Module({
// 导入ai模块,使根模块能够使用ai模块的功能
imports: [AiModule],
})
export class AppModule {}
3.3 核心代码实现与解析
以下按照“DTO参数校验→消息格式转换→Service业务逻辑→Controller路由配置”的顺序,逐一对核心代码进行解析,完整还原nestjs实现流式输出的全过程。
3.3.1 DTO参数校验(chat.dto.ts)
DTO(Data Transfer Object)用于定义前端请求参数的格式和校验规则,确保前端传递的参数符合后端要求,避免无效请求。本次学习中,前端需要传递对话历史(messages)和对话ID(id),因此创建ChatDto和Message类,使用class-validator进行参数校验:
// src/ai/dto/chat.dto.ts
import {
IsString,
IsArray,
ValidateNested, // 验证嵌套对象(messages数组中的每个元素是Message类型)
IsNotEmpty, // 校验字段不能为空
} from 'class-validator';
import { Type } from 'class-transformer'; // 转换嵌套对象的类型
// 单个消息的DTO(对话历史中的每条消息)
export class Message {
// 校验role:必须是字符串,且不能为空
@IsString()
@IsNotEmpty()
role: string; // 角色:user/assistant/system
// 校验content:必须是字符串,且不能为空
@IsString()
@IsNotEmpty()
content: string; // 消息内容
}
// 聊天请求的DTO(前端传递的整体参数)
export class ChatDto {
// 校验id:必须是字符串,且不能为空(对话唯一标识,用于关联对话历史)
@IsString()
@IsNotEmpty()
id: string;
// 校验messages:必须是数组,且数组中的每个元素是Message类型
@IsArray()
@ValidateNested({ each: true }) // each: true 表示校验数组中的每个元素
@Type(() => Message) // 将数组中的每个元素转换为Message类型(用于类型安全)
messages: Message[]; // 对话历史数组
}
核心解析:
- 装饰器说明:
-
@IsString():校验字段必须是字符串类型;
-
@IsNotEmpty():校验字段不能为空(避免空字符串、undefined、null);
-
@IsArray():校验字段必须是数组类型;
-
@ValidateNested({ each: true }):校验嵌套对象,each: true表示数组中的每个元素都需要按照Message类的规则校验;
-
@Type(() => Message):将数组中的每个元素转换为Message类型,确保类型安全,同时让class-validator能够正确校验嵌套对象。
- 参数说明:
-
id:对话的唯一标识,用于关联多轮对话历史(如持久化到数据库时使用);
-
messages:对话历史数组,每个元素包含role和content,对应前端传递的对话记录。
3.3.2 消息格式转换(核心辅助逻辑)
前端传递的Message格式(DTO)与LangChain对接LLM所需的ChatMessage格式存在差异,需编写转换函数,将前端DTO消息转换为LangChain支持的格式,确保LLM能够正确识别对话历史。
在ai.service.ts同级目录创建utils文件夹,新建message-transform.ts文件,编写转换函数:
// src/ai/utils/message-transform.ts
import { Message } from '../dto/chat.dto';
// 导入LangChain的ChatMessage类型
import { ChatMessage } from '@langchain/core/messages';
/**
* 将前端DTO消息数组转换为LangChain支持的ChatMessage数组
* @param messages 前端传递的Message数组(DTO格式)
* @returns LangChain支持的ChatMessage数组
*/
export function transformToChatMessages(messages: Message[]): ChatMessage[] {
return messages.map((msg) => {
// 根据role判断消息类型,转换为对应的ChatMessage实例
return new ChatMessage(msg.content, {
role: msg.role as 'user' | 'assistant' | 'system',
});
});
}
核心解析:
-
前端DTO的Message仅包含role和content两个字段,而LangChain的ChatMessage需要明确指定role类型(限定为user/assistant/system);
-
转换函数通过map循环,将每个DTO Message转换为ChatMessage实例,确保role类型符合LLM要求,避免格式错误;
-
该函数后续将在AiService中调用,作为对接LLM的前置步骤。
3.3.3 Service业务逻辑实现(ai.service.ts)
AiService是NestJS实现流式输出的核心,负责集成LangChain、对接DeepSeek LLM、处理流式响应、生成token流,所有复杂业务逻辑均封装在此,控制器仅负责接收请求和返回响应。
// src/ai/ai.service.ts
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ChatDto } from './dto/chat.dto';
import { transformToChatMessages } from './utils/message-transform';
// 导入LangChain相关依赖
import { DeepSeekChat } from '@langchain/deepseek';
import { ChatMessage } from '@langchain/core/messages';
import { Readable } from 'stream'; // Node.js Stream API,用于生成流式响应
@Injectable()
export class AiService {
// 初始化DeepSeek LLM客户端(依赖环境变量)
private readonly llm: DeepSeekChat;
constructor() {
// 从环境变量中获取DeepSeek API密钥和基础URL
const apiKey = process.env.DEEPSEEK_API_KEY;
const baseUrl = process.env.DEEPSEEK_BASE_URL;
// 校验环境变量是否配置(避免空值导致接口调用失败)
if (!apiKey || !baseUrl) {
throw new Error('DeepSeek API key or base URL is not configured in .env file');
}
// 初始化DeepSeek LLM客户端
this.llm = new DeepSeekChat({
apiKey: apiKey,
baseUrl: baseUrl,
modelName: 'deepseek-chat', // 使用的LLM模型(DeepSeek聊天模型)
temperature: 0.7, // 生成文本的随机性(0-1,值越大越随机)
maxTokens: 2048, // 单次响应的最大token数量
});
}
/**
* 核心方法:生成流式响应(对接LLM,逐token返回)
* @param chatDto 前端传递的请求参数(DTO格式,包含对话历史和对话ID)
* @returns Readable Stream(流式响应,供控制器返回给前端)
*/
async createStreamResponse(chatDto: ChatDto): Promise<Readable> {
try {
// 1. 转换消息格式:将前端DTO消息转换为LangChain支持的ChatMessage
const chatMessages: ChatMessage[] = transformToChatMessages(chatDto.messages);
// 2. 调用LLM,开启流式输出(核心!LangChain封装的流式方法)
const stream = await this.llm.stream(chatMessages);
// 3. 创建Node.js Readable Stream,用于将LLM的token流转换为前端可接收的格式
const responseStream = new Readable({
objectMode: true, // 允许流中传递对象(此处传递token字符串)
read() {}, // 空实现read方法,后续通过push方法推送数据
});
// 4. 监听LLM的token流,逐token推送至responseStream
for await (const chunk of stream) {
// chunk是LangChain返回的token块,提取content(增量token)
const token = chunk.content || '';
if (token) {
// 按照AI SDK约定的格式推送token(与mockjs保持一致:0:${JSON.stringify(token)}\n)
responseStream.push(`0:${JSON.stringify(token)}\n`);
}
}
// 5. LLM流式输出结束后,推送结束信号,关闭流
responseStream.push(null); // push(null)表示流结束
return responseStream;
} catch (error) {
// 错误处理:捕获LLM调用、消息转换过程中的异常,抛出HTTP异常
console.error('LLM stream error:', error);
throw new HttpException(
`Failed to generate stream response: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
核心解析(重点):
(1)LLM客户端初始化
-
在Service的构造函数中初始化DeepSeekChat客户端,依赖.env文件中的环境变量(API密钥和基础URL);
-
增加环境变量校验,避免因未配置API密钥导致后续调用失败;
-
配置模型参数(temperature、maxTokens):temperature控制生成文本的随机性,maxTokens限制单次响应的最大长度,可根据需求调整。
(2)流式响应核心方法createStreamResponse
该方法是Service的核心,接收前端传递的ChatDto,返回Readable Stream(流式响应),步骤如下:
-
消息格式转换:调用transformToChatMessages函数,将前端DTO的Message数组转换为LangChain支持的ChatMessage数组,确保LLM能够正确识别对话历史;
-
开启LLM流式输出:调用this.llm.stream(chatMessages),LangChain封装了LLM的流式调用逻辑,返回一个可迭代的token流;
-
创建响应流:初始化Node.js的Readable Stream(设置objectMode: true,允许推送字符串对象),用于将LLM的token流转换为前端可接收的格式;
-
逐token推送:通过for await...of循环监听LLM的token流,提取每个token的content,按照与mockjs一致的格式(0:${JSON.stringify(token)}\n)推送至响应流,确保前端AI SDK能够正常解析;
-
流结束处理:LLMtoken流读取完毕后,推送null信号,关闭响应流,告知前端响应结束;
-
错误处理:捕获整个过程中的异常(如LLM调用失败、消息转换失败),打印错误日志并抛出HTTP 500异常,便于前端捕获和提示用户。
(3)依赖注入特性
-
NestJS的@Injectable()装饰器将AiService标记为可注入的服务,后续可在AiController中通过构造函数注入,无需手动实例化;
-
这种设计实现了业务逻辑与请求处理的解耦,便于后续维护和扩展(如替换LLM模型、添加缓存逻辑等)。
3.3.4 Controller路由配置(ai.controller.ts)
AiController负责处理前端的HTTP请求,定义接口路由,接收请求参数(通过DTO校验),调用AiService的流式方法,最终返回流式响应给前端。
// src/ai/ai.controller.ts
import { Controller, Post, Body, Res, HttpStatus } from '@nestjs/common';
import { AiService } from './ai.service';
import { ChatDto } from './dto/chat.dto';
import { Response } from 'express'; // Express的Response对象,用于自定义流式响应
@Controller('api/ai') // 控制器根路由:/api/ai
export class AiController {
// 注入AiService(依赖注入,NestJS自动实例化)
constructor(private readonly aiService: AiService) {}
/**
* 流式聊天接口(与mockjs接口路径一致,便于前端无缝切换)
* @param chatDto 前端传递的请求参数(经过DTO校验)
* @param res Express Response对象,用于返回流式响应
*/
@Post('chat') // 接口路由:POST /api/ai/chat(与mockjs接口完全一致)
async streamChat(@Body() chatDto: ChatDto, @Res() res: Response) {
try {
// 1. 调用AiService的方法,获取流式响应
const stream = await this.aiService.createStreamResponse(chatDto);
// 2. 配置流式响应头(与mockjs保持一致,确保前端兼容)
res.setHeader('Content-Type', 'text/plain;charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked'); // 启用分块传输
res.setHeader('x-vercel-ai-data-stream', 'v1'); // AI SDK识别头
res.status(HttpStatus.OK); // 设置响应状态码200
// 3. 将Service返回的流管道(pipe)到响应对象,推送至前端
stream.pipe(res);
// 4. 监听流结束事件,确保响应正常关闭
stream.on('end', () => {
res.end();
});
// 5. 监听流错误事件,捕获异常并返回错误响应
stream.on('error', (error) => {
console.error('Stream pipe error:', error);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(`Stream error: ${error.message}`);
});
} catch (error) {
// 捕获Service抛出的异常,返回错误响应
res.status(error.getStatus()).send(error.getResponse());
}
}
}
核心解析(重点):
(1)路由配置与参数接收
-
@Controller('api/ai'):设置控制器的根路由为/api/ai,配合@Post('chat'),最终接口路径为POST /api/ai/chat,与mockjs的接口路径、请求方法完全一致,实现前端无缝切换(无需修改前端代码);
-
@Body() chatDto: ChatDto:接收前端POST请求的请求体,自动通过全局ValidationPipe校验(DTO校验),若参数不符合规则,直接返回400异常;
-
@Res() res: Response:注入Express的Response对象,用于自定义流式响应(设置响应头、管道流等),NestJS默认的响应方式无法满足流式输出需求,需手动操作res对象。
(2)流式响应返回逻辑
-
调用Service方法:获取AiService返回的Readable Stream(token流);
-
配置响应头:与mockjs保持完全一致,确保前端AI SDK能够正常识别和解析流式数据,同时解决中文乱码问题;
-
流管道推送:通过stream.pipe(res),将Service生成的token流“管道”到响应对象,自动逐块推送至前端,无需手动调用res.write();
-
流事件监听:监听stream的end和error事件,确保流正常关闭,同时捕获管道过程中的异常,返回对应的错误响应。
(3)与mockjs接口的兼容性设计
接口路径、请求方法、响应头、token格式均与mockjs保持一致,前端只需注释掉mockjs接口,修改useChatBot钩子中的api地址(改为http://localhost:3000/api/ai/chat),即可无缝对接NestJS后端,无需修改其他前端代码,极大降低了前后端联调成本。
3.4 前端对接nestjs后端(无缝切换)
当NestJS后端服务搭建完成后,前端只需修改useChatBot钩子中的api地址,即可从对接mockjs切换到对接真实后端,无需修改其他逻辑,实现无缝切换。
// src/hooks/useChatBot.js(修改后)
import { useChat } from '@ai-sdk/react';
export const useChatBot = () => {
return useChat({
// 对接NestJS后端接口(后端服务运行在localhost:3000)
api: 'http://localhost:3000/api/ai/chat',
onError: (error) => {
console.log(error,'chatbot error');
// 可添加用户提示(如“服务异常,请稍后再试”)
alert(`聊天异常:${error.message}`);
}
})
}
对接效果验证步骤
-
启动NestJS后端服务:npm run start:dev(开发模式,支持热更新);
-
关闭mockjs服务(注释vite.config.js中的mock配置,或重启前端服务时关闭mock);
-
启动前端服务:npm run dev;
-
发起对话:输入问题,点击Send按钮,验证流式输出效果(与mockjs效果一致,逐字渲染);
-
异常测试:故意修改.env文件中的API密钥,验证错误处理逻辑是否生效(前端提示异常,后端打印错误日志)。
3.5 nestjs流式输出的优缺点与适用场景
NestJS实现的流式输出是生产级别的解决方案,对接真实LLM接口,具备完整的业务逻辑和异常处理,其优缺点和适用场景如下:
3.5.1 优点
-
生产可用:对接真实LLM接口,支持高可用、可扩展,适合部署到生产环境;
-
功能完善:支持参数校验、错误处理、模块化开发,可扩展添加对话持久化、权限校验、缓存等业务逻辑;
-
类型安全:基于TypeScript开发,DTO校验确保请求参数规范,减少异常情况;
-
前端兼容:与mockjs接口格式完全一致,前端可无缝切换,降低联调成本;
-
可维护性高:NestJS的模块化、依赖注入设计,便于后续代码维护和功能扩展(如替换LLM模型、添加多模型支持)。
3.5.2 缺点
-
开发成本高:需搭建后端服务,配置模块、控制器、服务,开发流程比mockjs复杂;
-
依赖环境:需配置LLM API密钥,依赖后端服务器运行环境,开发测试时需启动后端服务;
-
性能依赖LLM:流式输出的速度取决于LLM的响应速度,若LLM接口延迟高,会影响前端体验。
3.5.3 适用场景
-
项目测试/生产阶段:需要对接真实LLM接口,提供稳定的流式输出服务;
-
复杂业务场景:需要添加对话持久化、权限校验、缓存、多模型切换等业务逻辑;
-
企业级应用:要求服务高可用、可扩展、易维护,适合多人协作开发。
3.6 第三章结尾总结
本章详细讲解了基于NestJS实现流式输出的完整流程,从环境搭建、模块化设计,到核心代码(DTO校验、Service业务逻辑、Controller路由)的实现,再到前端无缝对接,完整覆盖了生产级流式输出的核心要点。
核心总结如下:
-
整体流程:前端发起请求→Controller接收并校验参数→Service对接LLM生成token流→Controller返回流式响应→前端实时渲染;
-
关键技术:NestJS模块化与依赖注入、LangChain LLM集成、Node.js Stream API、HTTP分块传输编码,这些技术的结合实现了稳定的流式输出;
-
核心设计:与mockjs接口保持完全兼容,实现前端无缝切换;通过DTO校验确保参数规范;通过Service封装业务逻辑,实现解耦和可扩展;
-
实际应用:NestJS方案适用于生产环境,可根据需求扩展对话持久化、缓存、权限校验等功能;mockjs方案适用于开发测试阶段,快速验证前端逻辑。
通过本章的学习,可掌握生产级流式输出的实现技巧,理解前后端协同工作的核心逻辑,能够独立搭建NestJS流式接口,并对接前端实现AI聊天等场景的流式输出功能。同时,也为后续学习更复杂的流式场景(如多模型切换、流式文件传输)奠定了基础。