构建流式 AI 聊天机器人的两种路径:从底层原理到 LangChain 抽象

12 阅读7分钟

引言

在大语言模型(Large Language Model, LLM)技术迅猛发展的今天,构建一个具备实时流式响应能力的 AI 聊天机器人(Chatbot),已成为前端与后端开发者共同关注的核心场景。用户不再满足于“点击-等待-显示”的传统交互模式,而是期望看到内容如打字机般逐字浮现——这种体验不仅提升了感知速度,也增强了人机对话的自然感。

然而,实现这一看似简单的“逐字输出”功能,背后却涉及 HTTP 协议、流式数据处理、前后端协同、错误恢复机制等多重技术细节。更进一步,随着项目规模扩大,如何在保证功能的同时兼顾可维护性、类型安全与扩展性,成为工程化落地的关键挑战。

本文将基于真实项目结构,深入剖析两种主流实现路径:

  1. 基于 Vercel AI SDK + 原生 Node.js 流(mockjs 模拟)的底层实现
  2. 基于 LangChain + NestJS 的模块化、企业级抽象实现

我们将从协议原理、代码细节、架构设计、工程权衡四个维度展开,不仅告诉你“怎么做”,更解释“为什么这么做”。无论你是初学者希望理解流式通信的本质,还是资深工程师寻求架构演进方向,本文都将提供有价值的参考。


一、为何流式输出至关重要?

1.1 用户体验的质变

想象一下:你向 ChatGPT 提问“请写一篇关于春天的散文”,如果系统需要 8 秒才返回完整结果,你会感到焦虑甚至怀疑是否卡死。而若第一秒就看到“春日的阳光洒在……”,后续文字持续流动,即使总耗时相同,心理感受却截然不同——这就是首字延迟(Time to First Token, TTFT)对用户体验的决定性影响。

流式输出通过边生成边传输,让用户“所见即所得”,极大降低等待焦虑。

1.2 系统资源的优化

非流式方案需在服务端缓存完整响应后再一次性返回,这意味着:

  • 内存占用随响应长度线性增长;
  • 长文本生成可能导致 OOM(Out of Memory);
  • 并发能力受限。

而流式方案采用惰性生成+即时释放策略,内存仅需维持当前 token,显著提升系统吞吐量。

1.3 技术基础:SSE 与 Chunked Encoding

现代浏览器支持 **Server-Sent Events **(SSE) 协议,它基于 HTTP/1.1 的 Transfer-Encoding: chunked 机制,允许服务器持续向客户端推送文本数据。其基本格式如下:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"token": "春"}
data: {"token": "日"}
data: [DONE]

而 Vercel AI SDK 在此基础上定义了自己的轻量协议(稍后详述),使得前端能无缝解析流式内容。


二、方案一:原生流式代理 —— 手动掌控每一字节

2.1 架构概览

该方案采用极简架构:

  • 前端:使用 @ai-sdk/reactuseChat Hook;
  • 后端:一个独立的 chat.js 文件,作为 DeepSeek API 的透明流式代理
  • 无框架依赖,直接操作 Node.js 原生 req/res 对象。

适合快速验证、教学演示或轻量级项目。

2.2 核心代码深度解析(chat.js)

// 设置关键响应头
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('x-vercel-ai-data-stream', 'v1');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  • Transfer-Encoding: chunked:启用分块传输编码;
  • x-vercel-ai-data-stream: v1:Vercel SDK 自定义协议标识,前端据此识别数据格式;
  • Content-Type: text/plain:避免浏览器尝试解析为 JSON。

接着,向 DeepSeek 发起流式请求:

const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    model: 'deepseek-chat',
    messages,
    stream: true // 关键!开启流式模式
  })
});

DeepSeek 返回的是标准 SSE 流。我们通过 response.body.getReader() 获取底层可读流:

const reader = response.body.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) {
    if (line.startsWith('data: ') && line !== 'data: [DONE]') {
      try {
        const data = JSON.parse(line.slice(6));
        const content = data.choices[0]?.delta?.content || '';
        if (content) {
          // 转换为 Vercel SDK 协议格式
          res.write(`0:${JSON.stringify(content)}\n`);
        }
      } catch (e) {
        console.error('Parse error:', e);
      }
    }
  }
}
res.end();

关键细节说明:

  1. 缓冲区处理:网络传输可能将一行数据拆成多个 chunk,因此需用 buffer 拼接,确保每行完整;
  2. 协议转换:DeepSeek 的 data: {...} 需转为 0:"token"\n,这是 Vercel SDK 的内部约定;
  3. 错误隔离:单个 token 解析失败不应中断整个流,故包裹 try-catch

2.3 前端集成(useChatBot.ts)

import { useChat } from 'ai/react';

export const useChatbot = () => {
  return useChat({
    api: '/api/ai/chat', // 指向你的代理接口
    onError: (error) => {
      console.error('聊天发生错误:', error.message);
    }
  });
};

useChat 内部会:

  • 自动解析 0:... 格式的流;
  • 管理 messagesinputisLoading 等状态;
  • 支持重试、清除历史等交互逻辑。

开发者几乎无需关心底层通信细节。

2.4 优缺点再审视

优势

  • 代码量少(<100 行),部署简单;
  • 完全透明,便于调试和自定义协议;
  • 学习成本低,适合理解流式本质。

劣势

  • 无输入校验,恶意请求可能导致崩溃;
  • 错误处理粗糙,缺乏监控埋点;
  • 难以复用(如切换模型需重写整个代理);
  • 无法集成中间件(日志、鉴权、限流等)。

适用场景:个人博客、Hackathon、教学 Demo、临时 PoC。


三、方案二:LangChain + NestJS —— 构建可演进的企业级服务

3.1 架构全景图

该方案采用分层架构:

Frontend (React)
       ↓
AIController (接收请求,设置 SSE 头)
       ↓
AIService (业务逻辑,调用 LangChain)
       ↓
ChatDeepSeek (LangChain 封装的 DeepSeek 客户端)
       ↓
DeepSeek API

各层职责清晰,符合 SOLID 原则,支持单元测试、依赖注入、AOP 等高级特性。

3.2 输入校验:DTO 与 class-validator

// chat.dto.ts
import { IsString, IsNotEmpty, IsArray, ValidateNested, Type } from 'class-validator';

export class Message {
  @IsString() @IsNotEmpty()
  role: 'user' | 'assistant' | 'system';

  @IsString() @IsNotEmpty()
  content: string;
}

export class ChatDto {
  @IsString() @IsNotEmpty()
  id: string;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => Message)
  messages: Message[];
}

NestJS 在请求进入 Controller 前自动执行校验,非法请求直接返回 400,无需手动判断。

3.3 服务层:LangChain 的威力

// ai.service.ts
import { Injectable } from '@nestjs/common';
import { ChatDeepSeek } from '@langchain/community/chat_models/deepseek';
import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages';

@Injectable()
export class AIService {
  private chatModel: ChatDeepSeek;

  constructor() {
    this.chatModel = new ChatDeepSeek({
      configuration: { apiKey: process.env.DEEPSEEK_API_KEY },
      model: 'deepseek-chat',
      streaming: true, // 启用流式
    });
  }

  async chat(messages: Message[], onToken: (token: string) => void) {
    const langChainMessages = messages.map(msg => {
      switch (msg.role) {
        case 'user': return new HumanMessage(msg.content);
        case 'assistant': return new AIMessage(msg.content);
        case 'system': return new SystemMessage(msg.content);
        default: throw new Error(`Unknown role: ${msg.role}`);
      }
    });

    const stream = await this.chatModel.stream(langChainMessages);
    for await (const chunk of stream) {
      const content = chunk.content as string;
      if (content) onToken(content);
    }
  }
}

LangChain 带来的价值:

  • 统一接口:无论使用 OpenAI、Anthropic 还是 DeepSeek,调用方式一致;
  • 消息标准化HumanMessage/AIMessage 是生态通用格式;
  • 流式原生支持stream() 返回 AsyncIterable,天然契合 for await...of
  • 未来扩展:轻松接入 RAG(检索增强)、Agent、工具调用等高级功能。

3.4 控制器:SSE 响应封装

// ai.controller.ts
import { Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('ai')
export class AIController {
  constructor(private readonly aiService: AIService) {}

  @Post('chat')
  async chat(@Body() chatDto: ChatDto, @Res() res: Response) {
    // 设置 SSE 必需头部
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.setHeader('Access-Control-Allow-Origin', '*'); // 开发环境 CORS

    try {
      await this.aiService.chat(chatDto.messages, (token: string) => {
        // 注意:Vercel SDK 协议要求末尾有 \n\n
        res.write(`0:${JSON.stringify(token)}\n\n`);
      });
      res.end();
    } catch (error) {
      console.error('AI 服务异常:', error);
      res.status(500).end();
    }
  }
}

🔍 细节注意:Vercel SDK 要求每帧以 \n\n 结尾(而非 \n),否则前端可能无法正确解析。这是许多开发者踩坑的地方!

3.5 模块化组织与可测试性

// 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 {}

通过模块注册,NestJS 自动完成依赖注入。未来若需添加日志中间件、性能监控、多模型路由,只需扩展模块,不影响现有逻辑。

此外,AIService 可被单独单元测试:

// ai.service.spec.ts
describe('AIService', () => {
  let service: AIService;

  beforeEach(() => {
    service = new AIService();
  });

  it('should call onToken for each token', async () => {
    const mockCallback = jest.fn();
    await service.chat([{ role: 'user', content: 'hi' }], mockCallback);
    expect(mockCallback).toHaveBeenCalled();
  });
});

3.6 优缺点深度分析

优势

  • 强类型安全:从 DTO 到 Service 全链路 TypeScript 保障;
  • 高内聚低耦合:各层职责单一,修改互不影响;
  • 生态兼容:LangChain 支持 100+ 模型与工具,未来迁移成本低;
  • 工程规范:符合企业级开发标准,适合团队协作。

劣势

  • 初期搭建成本高(需配置 NestJS、DTO、依赖等);
  • 运行时依赖较多,冷启动略慢;
  • 对小型项目略显“重”。

适用场景:SaaS 产品、企业内部工具、需长期迭代的 AI 应用。


四、对比总结:技术选型决策指南

维度方案一(原生代理)方案二(LangChain + NestJS)
开发效率⭐⭐⭐⭐⭐(10 分钟上线)⭐⭐⭐(需搭建框架)
代码可读性中(逻辑集中但混乱)高(分层清晰)
类型安全✅(全链路 TS)
错误处理基础完善(全局异常过滤器)
扩展性差(硬编码模型)极强(插拔式模型/工具)
测试支持强(单元/集成测试)
学习曲线中高
适用规模个人 / 小型项目团队 / 企业级项目

五、结语:在原理与抽象之间找到平衡

构建一个流式 AI 聊天机器人,表面上是“调个 API”,实则是一场对工程哲学的考验

方案一让我们回归本质:理解 HTTP 流、SSE 协议、字节缓冲、协议转换——这是每个后端工程师的必修课。
方案二则教会我们如何在复杂性中保持秩序:通过分层、抽象、类型系统,将混沌封装为可管理的模块。

正如《计算机程序的构造和解释》所言:“程序=数据结构+算法”,而现代工程实践告诉我们:“系统=抽象+组合”。

无论你选择哪条路径,请记住:工具服务于目标,而非相反。理解底层,才能善用高层;拥抱抽象,方能驾驭复杂。

延伸思考

  • 如何在流式过程中支持“停止生成”?
  • 如何记录 token 使用量用于计费?
  • 如何在流式响应中插入“思考过程”或“工具调用”?

这些问题的答案,或许就在 LangChain 的 Agent 或 Vercel AI SDK 的 streamText 高级用法中。