拒绝黑盒!NestJS + LangChain 实战保姆级拆解,手把手教你搞定双 Token 与 AI 大脑

在上一篇文章中,我们给接口穿上了 DTO 的"防弹衣",用 Prisma 把复杂的 SQL 变成了优雅的"套娃"查询。很多兄弟在评论区催更:"接口安全有了,那用户登录怎么搞?还有,怎么让后端拥有'大脑',接上 AI 大模型?"

别急,今天咱们就继续深挖这个实战项目的核心。我们将把目光投向 AuthModule(认证模块)AIModule(AI 智能模块) 。准备好了吗?咱们继续发车,逐行拆解这套"双保险"认证与"AI 大脑"的底层逻辑!🚀

一、 JWT 双 Token 机制:给登录态装上"双保险"

很多新手在做用户登录时,习惯只发一个 Token,有效期设个 7 天。这就像你把家里的钥匙给了朋友,并且告诉他:"这把钥匙你拿好,7天内随时能进我家。"一旦钥匙丢了,这 7 天里小偷随时能光顾。

为了解决这个安全与体验的矛盾,咱们的项目采用了业界标准的 双 Token 机制

1. 为什么要用两张"通行证"?

我们可以把 Token 想象成进入公司大楼的证件:

  • Access Token(门禁卡) :有效期只有 15分钟。你每次进电梯、刷饭卡都要用。因为它用得频繁,所以有效期极短。就算被坏人偷拍,15分钟后也就失效了,攻击窗口极小。
  • Refresh Token(身份证) :有效期长达 7天。它平时藏在你的口袋里,只有当门禁卡过期时,你才需要掏出身份证去前台换一张新的门禁卡。因为它极少在网络中传输,所以非常安全。

2. 逐行拆解 auth.service.ts:Token 的诞生与续命

来看看咱们的 AuthService 是怎么玩转这套机制的:

typescript 复制代码
// 核心方法:并发生成两张 Token
private async generateTokens(id: string, name: string) {
  // 1. 准备载荷(Payload),也就是 Token 里要携带的用户信息
  const payload = {
    sub: id,    // JWT 规范里的 subject,通常用来放用户 ID
    name        // 顺便把用户名也塞进去,方便前端展示
  };

  // 2. 使用 Promise.all 并发生成,提升响应速度
  const [at, rt] = await Promise.all([
    // 生成 Access Token:有效期 15分钟,高频使用,短期有效
    this.jwtService.signAsync(payload, {
      expiresIn: '15m', 
      secret: process.env.TOKEN_SECRET
    }),
    // 生成 Refresh Token:有效期 7天,低频使用,长期有效
    this.jwtService.signAsync(payload, {
      expiresIn: '7d',
      secret: process.env.TOKEN_SECRET
    }),
  ])
  return {
    access_token: at,
    refresh_token: rt
  }
}

逐行拆解:

  • payload:JWT 本质上就是一段经过加密的 JSON 字符串。这里我们把用户 ID (sub) 和用户名 (name) 放进去,以后只要解析 Token,就能知道"是谁在访问"。
  • Promise.all([...]):生成两个 Token 是互不干扰的独立任务。用 Promise.all 让它们同时去加密,比串行执行快了整整一倍。

接下来是登录和刷新 Token 的逻辑:

typescript 复制代码
async login(loginDto: LoginDto) {
  const { name, password } = loginDto;
  // 1. 根据用户名去数据库捞人
  const user = await this.prisma.user.findUnique({ where: { name } })
  
  // 2. 如果用户不存在,或者 bcrypt 比对密码失败,直接抛出 401 异常
  if(!user || !(await bcrypt.compare(password, user.password))) {
    throw new UnauthorizedException('用户名或密码错误')
  }

  // 3. 密码正确,颁发双 Token,顺便把用户基本信息返回给前端
  const tokens = await this.generateTokens(user.id.toString(), user.name);
  return { ...tokens, user: { id: user.id.toString(), name: user.name } }
}

async refreshToken(rt: string) {
  try {
    // 1. 尝试用服务器的密钥去"解码"前端传来的 Refresh Token
    const payload = await this.jwtService.verifyAsync(rt, {
      secret: process.env.TOKEN_SECRET
    });
    // 2. 解码成功,说明 Token 没过期也没被篡改,重新签发一对新 Token
    return this.generateTokens(payload.sub, payload.name);
  } catch(e) {
    // 3. 解码失败(比如过期了),让用户重新登录
    throw new UnauthorizedException('Refresh Token 已失效,请重新登录')
  }
}

逐行拆解:

  • bcrypt.compare:注册时我们用 bcrypt.hash 把密码加密成了一串乱码存进数据库。登录时,必须用 compare 来比对明文和密文,这是保障用户密码安全的核心防线。
  • verifyAsync:这是刷新 Token 的关键。它不仅能检查 Token 是否过期,还能验证 Token 的签名是否被篡改。只要验证通过,我们就无条件信任它,并给用户换发新的"门禁卡"和"身份证"。

3. 守卫的艺术:像安检门一样自动拦截

Token 发出去了,怎么在每次请求时校验它呢?NestJS 的 PassportGuard 完美解决了这个问题。

scala 复制代码
// jwt.strategy.ts:定义校验规则
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // 1. 告诉 Passport:去 HTTP 请求头的 Authorization 字段里,提取 Bearer 后面的 Token
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false, // 2. 绝不放过过期的 Token
      secretOrKey: process.env.TOKEN_SECRET || "" // 3. 用这个密钥去解密
    });
  }
  // 4. 解密成功后,Passport 会自动调用这个方法,把 payload 转换成标准的用户对象
  async validate(payload) {
    return { id: payload.sub, name: payload.name }
  }
}

// jwt-auth.guard.ts:真正的"安检门"
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt'){}

逐行拆解:

  • JwtStrategy:它是背后的"安检员"。它负责从请求头里把 Token 抠出来,用密钥解密。如果解密成功,就把里面的用户信息(idname)提取出来。
  • JwtAuthGuard:它是挡在接口前的"安检门"。当你在 Controller 里加上 @UseGuards(JwtAuthGuard),请求一来,先过安检。安检员(Strategy)验证通过后,会把用户信息自动挂载到 req.user 上,你的业务代码直接拿来用就行,完全不用操心 Token 校验的繁琐细节。

二、 LangChain 驱动 AI:让后端拥有"大脑"

搞定了安全,咱们来点刺激的。现在的后端不仅要处理 CRUD,还得能跟大模型(LLM)对话。咱们的项目集成了 LangChainDeepSeekOpenAI,实现了流式对话、语义搜索、RAG 知识库甚至自动生成 Git 提交日志。

1. 模型初始化:把 AI 变成 Service 的私有属性

AIService 的构造函数里,我们把各种 AI 能力都准备好了:

arduino 复制代码
constructor() {
  // 1. 初始化 DeepSeek 大模型,用于日常对话和 Git 日志生成
  this.chatModel = new ChatDeepSeek({
    configuration: { apiKey: process.env.DEEPSEEK_API_KEY, baseURL: process.env.DEEPSEEK_BASE_URL },
    model: 'deepseek-chat',
    temperature: 0.7, // 温度值,0.7 代表回答既有一定创造性,又不会太离谱
    streaming: true   // 【核心】开启流式输出,实现打字机效果
  })
  // 2. 初始化 OpenAI 的 Embeddings 模型,用于把文字变成向量(做语义搜索和 RAG 的基石)
  this.embeddings = new OpenAIEmbeddings({ ... })
  // 3. 初始化 DALL-E 3,用于根据用户名画头像
  this.imageGenerator = new DallEAPIWrapper({ ... })
  
  // 4. 启动时,异步加载本地的文章向量数据
  this.loadPosts();
}

逐行拆解:

  • streaming: true:这是实现"打字机效果"的关键。如果不打开它,大模型会憋半天,然后一次性把几百个字全吐出来;打开后,它每生成一个字,后端就能立刻收到并推给前端。

2. 流式输出(SSE)实战:让 AI 的回答"流"出来

前端要实现类似 ChatGPT 那种逐字蹦出文字的效果,后端必须使用 SSE(Server-Sent Events) 。来看看 AIController 是怎么做的:

less 复制代码
@Post('chat')
async chat(@Body() chatDto: ChatDto, @Res() res) {
  // 1. 设置 SSE 必需的 HTTP 响应头
  res.setHeader('Content-Type', 'text/event-stream'); // 告诉浏览器这是流数据
  res.setHeader('Cache-Control', 'no-cache');         // 禁止缓存,保证每次都是最新生成的
  res.setHeader('Connection', 'keep-alive');          // 保持长连接不断开

  try { 
    // 2. 调用 Service 层的 chat 方法,传入一个回调函数
    await this.aiService.chat(chatDto.messages, (token: string) => {
      // 3. 每当大模型吐出一个字(token),就立刻通过 res.write 写给前端
      res.write(`0:${JSON.stringify(token)}\n`);
    })
    res.end(); // 4. 全部生成完毕,结束连接
  } catch(err) {
    console.error(err);
    res.status(500).end(); // 出错也要优雅地关闭连接
  }
}

逐行拆解:

  • @Res() res:在 NestJS 中,默认情况下框架会自动帮你把返回值序列化成 JSON。但 SSE 需要我们要手动控制 HTTP 响应流,所以必须注入原生的 Response 对象 (@Res())。
  • res.write(...):这就是"打字机"的马达。Service 层每从大模型那里拿到一个文字碎片,Controller 就立刻把它塞进网络管道发给前端。

Service 层的配合也非常简单:

typescript 复制代码
async chat(messages: Message[], onToken: (token: string) => void) {
  // 1. 把前端传来的简单消息数组,转换成 LangChain 能看懂的 HumanMessage/AIMessage 对象
  const langChainMessages = convertToLangChainMessages(messages);
  
  // 2. 调用大模型的 stream 方法,它会返回一个异步迭代器
  const stream = await this.chatModel.stream(langChainMessages);
  
  // 3. 遍历这个流,每拿到一个字块(chunk),就通过回调函数传给 Controller
  for await ( const chunk of stream) {
    const content = chunk.content as string;
    if (content) {
      onToken(content); // 触发 Controller 里的 res.write
    }
  }
}

3. RAG 与语义搜索:给 AI 装上"本地知识库"

大模型有个毛病,就是会"一本正经地胡说八道"(幻觉)。为了解决这个问题,我们引入了 RAG(检索增强生成) 。它的原理是:在问 AI 之前,先去自己的知识库里找相关资料,然后把资料和问题一起丢给 AI。

语义搜索 (search 方法):

typescript 复制代码
async search(keyword: string, topK=3) {
  // 1. 把用户的搜索关键词,通过 Embeddings 模型变成一个高维向量
  const vector = await this.embeddings.embedQuery(keyword);
  
  // 2. 遍历本地加载的文章向量,计算余弦相似度(Cosine Similarity)
  const results = this.posts.map(post => ({
    ...post,
    similarity: cosineSimilarity(vector, post.embedding)
  }))
  // 3. 按相似度从高到低排序,取前 3 个最相关的文章标题
  .sort((a, b) => b.similarity - a.similarity)
  .slice(0, topK)
  .map(item => item.title);

  return { code: 0, data: results }
}

RAG 问答 (rag 方法):

typescript 复制代码
async rag(question: string) {
  // 1. 动态构建一个内存向量数据库,把我们的知识库(比如 React、NestJS 的介绍)存进去
  const vectorStore = await MemoryVectorStore.fromDocuments([...], this.embeddings)
  
  // 2. 拿着用户的问题去向量库里"相似度搜索",找出最相关的 1 条资料
  const docs = await vectorStore.similaritySearch(question, 1);
  const context = docs.map(d => d.pageContent).join('\n');
  
  // 3. 【核心】把检索到的资料(Context)拼接到 Prompt 里,一起发给大模型
  const prompt = `
    你是一个专业的JS工程师,请基于下面资料回答问题。
    资料:${context}
    问题:${question}
  `;
  
  // 4. 大模型基于资料生成回答,极大减少了胡编乱造的概率
  const res = await this.chatModel.invoke(prompt);
  return res.content;
}

逐行拆解:

  • cosineSimilarity:这是向量搜索的灵魂。它通过计算两个向量在多维空间中的夹角余弦值,来判断两段文字在"语义"上有多接近。比如搜"前端框架",它能精准匹配到"React",哪怕文字里根本没有"前端框架"这四个字。
  • MemoryVectorStore:这是一个存在于内存中的向量数据库。对于小型项目或 Demo 来说,它无需部署额外的数据库(如 Milvus、PgVector),启动快、零成本,是学习 RAG 的绝佳工具。

4. AI 的奇技淫巧:自动生成 Git Commit Message

最后,给大家展示一个超级实用的 AI 小功能:根据 git diff 自动生成符合规范的提交日志。

typescript 复制代码
async git(diff: string) {
  // 1. 使用 LangChain 的提示词模板,预设好系统指令(System Prompt)
  const prompt = ChatPromptTemplate.fromMessages([
    ["system", `你是资深代码审核专家。请根据用户提供的 git diff 内容,生成一段
      符合Conventional Commits 规范的提交日志。
      要求:1. 格式为<type>(scope):<subject>。2. 保持简洁。3. 只输出文本。`],
    ["user", "{diff_content}"]
  ]);
  
  // 2. 搭建处理链:提示词 -> 大模型 -> 字符串解析器
  const chain = prompt.pipe(this.chatModel).pipe(new StringOutputParser());
  
  // 3. 注入具体的 diff 内容,执行链条
  const result = await chain.invoke({ diff_content: diff })
  return { result }
}

逐行拆解:

  • ChatPromptTemplate:它允许我们用模板化的方式管理 Prompt。把复杂的指令写在 system 里,把动态变化的内容(如 diff)用占位符 {diff_content} 代替。
  • .pipe(...):这是 LangChain 最优雅的"链式调用"。数据像水流一样,经过提示词组装,流进大模型,最后经过输出解析器清洗,拿到纯净的结果。

结语

从 JWT 双 Token 的"安检门",到 LangChain 驱动的流式对话与 RAG 知识库,NestJS 展现出了极强的扩展性。它不仅能稳稳地守住后端的安全底线,还能轻松地接上最前沿的 AI 能力,让传统后端真正长出"大脑"。

当你用 res.write 实现流式推送,用 MemoryVectorStore 搭建起私有知识库时,你会发现,AI 应用开发并没有想象中那么神秘。

不过,代码在本地跑得再欢,上线部署才是终极考验。下一期,我们将讲解如何将这套 NestJS + Prisma + AI 的系统容器化,通过 DockerDocker Compose 一键部署上线!想知道怎么把环境配置打包带走吗?点个关注,咱们下期见!👋

相关推荐
云恒要逆袭1 小时前
运行你的第一个Docker容器
后端·docker·容器
SL_staff1 小时前
3周搭完MES系统:JVS低代码+JVS-IoT物联网的实战记录
java·前端·低代码
秋天的一阵风1 小时前
Vue 3 里被严重低估的 API:InjectionKey
前端·javascript·vue.js
恋猫de小郭1 小时前
Flutter Patchwork,不用 Fork 改依赖包源码的第三方工具
android·前端·flutter
MacroZheng1 小时前
斩获20w star!Claude Code最强插件,AI编程必备!
java·人工智能·后端
IT_陈寒1 小时前
Vite打包后的路径问题差点让我改了一天代码
前端·人工智能·后端
禅思院1 小时前
前端部署“三层漏斗”完全指南:从CI/CD到自动回滚的工程化实战【基石】
前端·架构·前端框架
黄林晴2 小时前
AI时代终端窗口堆成山?这款工具让我爱不释手
前端
铁皮饭盒2 小时前
Bun 多线程有多快?postMessage 传输字符串比 Node.js 快 400 倍!
前端·javascript·后端