学习笔记:基于mockjs和nestjs实现流式输出

3 阅读28分钟

前言

在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 核心技术栈与工具

结合本次学习的代码内容,实现流式输出涉及的核心技术栈与工具如下,需提前掌握基础用法:

  1. 前端技术:React(Next.js)、@ai-sdk/react(Vercel AI SDK,封装了流式输出的前端逻辑)、shadcn UI(ScrollArea组件,优化滚动体验)、TypeScript(类型校验);

  2. 模拟工具:mockjs(前端Mock工具,用于模拟后端流式接口,快速验证前端逻辑);

  3. 后端框架:NestJS(Node.js后端框架,支持依赖注入、模块化开发,适合构建可扩展的后端服务);

  4. LLM集成工具:LangChain(大语言模型集成框架,简化与DeepSeek等LLM接口的对接,提供流式输出封装);

  5. 协议与规范:SSE(Server-Sent Events,服务器向客户端单向推送事件流,适合流式输出场景)、HTTP分块传输编码;

  6. 辅助工具:dotenv(加载环境变量)、class-validator/class-transformer(NestJS中实现参数校验)。

1.3 核心流程梳理

无论采用mockjs还是nestjs实现流式输出,整体流程均可分为3个核心步骤,前后端协同工作:

  1. 前端发起请求:前端通过AI SDK(@ai-sdk/react)发起请求,指定流式输出模式,同时监听服务器推送的事件流;

  2. 服务器生成流式响应:服务器(mockjs/nestjs)接收请求后,对接LLM(mockjs模拟LLM生成逻辑,nestjs对接真实LLM),逐token生成响应内容,通过分块传输(Chunked)或SSE协议实时推送至前端;

  3. 前端接收并渲染:前端监听服务器推送的每个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个核心响应头:

  1. Content-Type: text/plain;charset=utf-8:设置响应类型为纯文本,同时指定字符编码为utf-8,解决中文乱码问题;

  2. Transfer-Encoding: chunked:启用HTTP分块传输编码,告诉浏览器响应体将分多个数据块传输,无需提前知道总大小;

  3. 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流式输出的读取与解析:

  1. 发起LLM请求:通过fetch请求DeepSeek的聊天接口,开启stream: true,告诉LLM返回流式响应;

  2. 获取读取器:response.body.getReader() 获取响应体的读取器,用于逐块读取LLM输出的二进制数据;

  3. 解码数据块:通过TextDecoder将二进制Buffer解码为字符串,得到LLM返回的token流;

  4. 解析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);

  1. 推送token:若解析到有效token,通过res.write()将token写入响应流,推送至前端,格式需符合AI SDK约定(0:${JSON.stringify(content)}\n)。

注意:循环读取直到done为true(读取完毕),然后调用res.end()结束响应流,避免前端一直等待响应。

(5)错误处理逻辑

代码中添加了两层错误处理,确保mock服务稳定运行:

  1. 内层try-catch:捕获请求体解析、LLM请求、JSON解析过程中的异常,避免单个请求失败导致整个mock服务崩溃;

  2. 外层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)核心渲染逻辑拆解
  1. 滚动区域优化

使用shadcn UI的ScrollArea组件替代原生滚动条,原因是原生滚动条样式丑陋、体验较差,ScrollArea组件自带优化后的样式,同时支持自动滚动到底部(后续可添加),提升用户体验。组件的className设置为flex-1,确保滚动区域占满剩余屏幕空间。

  1. 对话历史渲染
  • 空状态提示:当messages数组为空时,显示提示文本“Start a conversation with DeepSeek...”,引导用户发起对话;

  • 消息列表渲染:通过map循环渲染每条消息,根据message.role设置对齐方式(用户消息右对齐,助手消息左对齐);

  • 消息样式:设置最大宽度为80%,避免消息过长;根据角色设置不同的背景色,区分用户和助手消息;

  • key的使用:用index作为key(兜底方案),实际开发中优先使用message.id(唯一标识),避免列表渲染异常。

  1. 流式加载状态

当isLoading为true(正在接收token)时,显示一个灰色背景的提示框,里面有“...”脉冲动画,告诉用户“正在思考中”,提升交互体验。

  1. 输入框与提交按钮
  • 输入框:value绑定input(AI SDK管理),onChange绑定handleInputChange(自动更新input值);加载中(isLoading)禁用输入框,避免重复提交;

  • 提交按钮:type设为submit,绑定表单提交事件;禁用条件:加载中(isLoading)或输入为空(!input.trim()),避免无效提交。

2.3.3 流式输出效果验证与调试

完成mockjs接口和前端组件开发后,需要验证流式输出效果,同时进行调试,确保功能正常。

(1)效果验证步骤
  1. 启动开发服务器:npm run dev(Next.js/Vite),确保mock服务正常启用;

  2. 访问Chat组件页面:在浏览器中打开http://localhost:3000(默认端口),进入聊天页面;

  3. 发起对话:在输入框中输入问题(如“什么是流式输出?”),点击Send按钮;

  4. 验证流式效果:观察助手消息,应呈现“打字机”效果,逐字显示响应内容,同时加载状态(...)消失;

  5. 多轮对话验证:继续输入问题,验证对话历史是否正常保留,流式输出是否持续有效。

(2)调试技巧
  1. 控制台调试
  • 前端控制台:打印messages、input、isLoading等参数,查看是否正常更新;

  • 后端(mock)控制台:打印chunk、content等参数,查看LLM token是否正常解析、推送。

  1. 网络面板调试

打开浏览器F12→Network,找到「/api/ai/chat」请求,查看Response/Preview面板,流式输出时会显示“正在加载”,同时可以看到逐块传输的token数据,确认响应头是否正确配置(Transfer-Encoding: chunked)。

  1. 常见问题排查
  • 无流式效果,一次性返回所有内容:检查mock配置中是否启用rawResponse,是否设置了Transfer-Encoding: chunked响应头;

  • 加载状态一直显示,无响应内容:检查mock接口路径与前端api参数是否一致,请求方法是否为POST,控制台是否有报错;

  • 中文乱码:检查响应头中是否设置了charset=utf-8;

  • mock接口未命中:检查mock配置文件路径是否正确,vite-plugin-mock是否启用,重启开发服务器尝试。

2.4 mockjs流式输出的优缺点与适用场景

通过mockjs实现流式输出,主要用于开发测试阶段,有其明确的优缺点和适用场景,需合理使用。

2.4.1 优点

  1. 开发效率高:无需搭建后端服务,仅通过前端配置即可模拟流式接口,快速验证前端逻辑;

  2. 调试方便:可自由模拟LLM的token生成速度、响应内容,便于调试前端的加载状态、渲染逻辑;

  3. 降低联调成本:前端可独立开发、测试,无需等待后端接口开发完成;

  4. 贴近真实场景:模拟了LLM流式生成、分块传输、SSE推送等核心逻辑,与真实后端接口的交互方式一致。

2.4.2 缺点

  1. 仅适用于开发测试:无法对接真实LLM接口,不能用于生产环境;

  2. 功能有限:无法实现复杂的后端业务逻辑(如对话历史持久化、权限校验、参数校验);

  3. 存在环境差异: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[]; // 对话历史数组
}

核心解析:

  1. 装饰器说明
  • @IsString():校验字段必须是字符串类型;

  • @IsNotEmpty():校验字段不能为空(避免空字符串、undefined、null);

  • @IsArray():校验字段必须是数组类型;

  • @ValidateNested({ each: true }):校验嵌套对象,each: true表示数组中的每个元素都需要按照Message类的规则校验;

  • @Type(() => Message):将数组中的每个元素转换为Message类型,确保类型安全,同时让class-validator能够正确校验嵌套对象。

  1. 参数说明
  • 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(流式响应),步骤如下:

  1. 消息格式转换:调用transformToChatMessages函数,将前端DTO的Message数组转换为LangChain支持的ChatMessage数组,确保LLM能够正确识别对话历史;

  2. 开启LLM流式输出:调用this.llm.stream(chatMessages),LangChain封装了LLM的流式调用逻辑,返回一个可迭代的token流;

  3. 创建响应流:初始化Node.js的Readable Stream(设置objectMode: true,允许推送字符串对象),用于将LLM的token流转换为前端可接收的格式;

  4. 逐token推送:通过for await...of循环监听LLM的token流,提取每个token的content,按照与mockjs一致的格式(0:${JSON.stringify(token)}\n)推送至响应流,确保前端AI SDK能够正常解析;

  5. 流结束处理:LLMtoken流读取完毕后,推送null信号,关闭响应流,告知前端响应结束;

  6. 错误处理:捕获整个过程中的异常(如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)流式响应返回逻辑
  1. 调用Service方法:获取AiService返回的Readable Stream(token流);

  2. 配置响应头:与mockjs保持完全一致,确保前端AI SDK能够正常识别和解析流式数据,同时解决中文乱码问题;

  3. 流管道推送:通过stream.pipe(res),将Service生成的token流“管道”到响应对象,自动逐块推送至前端,无需手动调用res.write();

  4. 流事件监听:监听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}`);
        }
    })
}
对接效果验证步骤
  1. 启动NestJS后端服务:npm run start:dev(开发模式,支持热更新);

  2. 关闭mockjs服务(注释vite.config.js中的mock配置,或重启前端服务时关闭mock);

  3. 启动前端服务:npm run dev;

  4. 发起对话:输入问题,点击Send按钮,验证流式输出效果(与mockjs效果一致,逐字渲染);

  5. 异常测试:故意修改.env文件中的API密钥,验证错误处理逻辑是否生效(前端提示异常,后端打印错误日志)。

3.5 nestjs流式输出的优缺点与适用场景

NestJS实现的流式输出是生产级别的解决方案,对接真实LLM接口,具备完整的业务逻辑和异常处理,其优缺点和适用场景如下:

3.5.1 优点
  1. 生产可用:对接真实LLM接口,支持高可用、可扩展,适合部署到生产环境;

  2. 功能完善:支持参数校验、错误处理、模块化开发,可扩展添加对话持久化、权限校验、缓存等业务逻辑;

  3. 类型安全:基于TypeScript开发,DTO校验确保请求参数规范,减少异常情况;

  4. 前端兼容:与mockjs接口格式完全一致,前端可无缝切换,降低联调成本;

  5. 可维护性高:NestJS的模块化、依赖注入设计,便于后续代码维护和功能扩展(如替换LLM模型、添加多模型支持)。

3.5.2 缺点
  1. 开发成本高:需搭建后端服务,配置模块、控制器、服务,开发流程比mockjs复杂;

  2. 依赖环境:需配置LLM API密钥,依赖后端服务器运行环境,开发测试时需启动后端服务;

  3. 性能依赖LLM:流式输出的速度取决于LLM的响应速度,若LLM接口延迟高,会影响前端体验。

3.5.3 适用场景
  • 项目测试/生产阶段:需要对接真实LLM接口,提供稳定的流式输出服务;

  • 复杂业务场景:需要添加对话持久化、权限校验、缓存、多模型切换等业务逻辑;

  • 企业级应用:要求服务高可用、可扩展、易维护,适合多人协作开发。

3.6 第三章结尾总结

本章详细讲解了基于NestJS实现流式输出的完整流程,从环境搭建、模块化设计,到核心代码(DTO校验、Service业务逻辑、Controller路由)的实现,再到前端无缝对接,完整覆盖了生产级流式输出的核心要点。

核心总结如下:

  1. 整体流程:前端发起请求→Controller接收并校验参数→Service对接LLM生成token流→Controller返回流式响应→前端实时渲染;

  2. 关键技术:NestJS模块化与依赖注入、LangChain LLM集成、Node.js Stream API、HTTP分块传输编码,这些技术的结合实现了稳定的流式输出;

  3. 核心设计:与mockjs接口保持完全兼容,实现前端无缝切换;通过DTO校验确保参数规范;通过Service封装业务逻辑,实现解耦和可扩展;

  4. 实际应用:NestJS方案适用于生产环境,可根据需求扩展对话持久化、缓存、权限校验等功能;mockjs方案适用于开发测试阶段,快速验证前端逻辑。

通过本章的学习,可掌握生产级流式输出的实现技巧,理解前后端协同工作的核心逻辑,能够独立搭建NestJS流式接口,并对接前端实现AI聊天等场景的流式输出功能。同时,也为后续学习更复杂的流式场景(如多模型切换、流式文件传输)奠定了基础。