
1. 为什么要在 NestJs 里给 Deepseek 加一层统一 Key做 AI 聊天助手最容易被低估的不是模型效果而是凭证管理。你一开始可能只在.env里写一个DEEPSEEK_API_KEY跑得挺顺。等到项目里同时出现 Deepseek、Claude、GPT 多个模型或者前端、后端、定时任务、Agent 各要一份 Key 时麻烦就来了Key 散落在多个文件、换模型要改代码、额度用超了不知道是哪个服务烧的、团队协作时还得把 Key 发来发去。我这次要搭的是一个 NestJs MongoDB Deepseek Langchain 的 AI 聊天助手核心诉求有三个第一模型凭证统一走 TaoToken 的 API 通道业务代码只认一个 baseURL 和一个 Key第二会话上下文持久化到 MongoDB刷新页面还能接着聊第三配置骨架可复制换模型只改配置不改逻辑。TaoToken 在这里扮演的角色是「统一 Key / API 通道管理」。你可以把它理解成一个模型调用的统一入口官网在 https://taotoken.netAPI 端点是 https://taotoken.net/api。它兼容 OpenAI 风格的接口协议所以 Langchain 的ChatOpenAI可以直接对接不用为每个模型写一套 SDK。对 NestJs 这种模块化框架来说这意味着AiModule只需要维护一份配置ChatModule只管业务职责非常干净。这篇文章适合谁已经会一点 NestJs、想跑通带持久化上下文的聊天接口的后端同学正在纠结多模型 Key 怎么管的开发者以及想用 Langchain 但不想被各家 SDK 差异折腾的人。下面从环境准备一路写到端到端验证配置骨架可以直接抄。2. TaoToken 前置拿 Key、认端点、定模型名在写代码之前先把凭证和端点确认清楚这一步做扎实后面能省掉大量 401 和 404 排查。2.1 获取 API Key登录 TaoToken 控制台在 API Keys 页面创建一个新的 Key。建议按用途命名比如nest-chat-dev方便后续区分开发和生产。创建后立刻复制保存页面刷新后通常不再完整显示。控制台地址https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsoleAPI Keys 管理页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys2.2 确认 API 端点与模型名TaoToken 的 API 基础地址是https://taotoken.net/api注意这里不加 UTM 参数它是给程序调用的。Langchain 的ChatOpenAI需要的是baseURL填这个地址即可。模型名方面Deepseek 对话模型一般用deepseek-chat。如果你不确定当前账号下有哪些可用模型可以先用模型对话页面手动发一条消息验证确认模型名和额度都正常再写进代码。模型对话入口https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat2.3 环境与依赖版本本地需要 Node 20、MongoDB 8.0。NestJs 用 11.xLangchain 用 1.x 系列。关键依赖如下{ dependencies: { langchain/core: ^1.1.39, langchain/openai: ^1.4.2, nestjs/common: ^11.0.1, nestjs/config: ^4.0.3, nestjs/core: ^11.0.1, nestjs/mongoose: ^11.0.4, nestjs/platform-express: ^11.0.1, langchain: ^1.3.0, mongoose: ^9.4.1, reflect-metadata: ^0.2.2, rxjs: ^7.8.1 } }这里没有引入 Deepseek 官方 SDK因为走 TaoToken 的 OpenAI 兼容通道后langchain/openai一个包就够了。少一个依赖就少一处版本冲突。3. 可复制配置骨架settings.json 与 config.toml配置这块我给了两种格式你可以按团队习惯选。NestJs 项目本身用.env最顺但如果你有跨语言服务或需要结构化配置settings.json和config.toml更清晰。3.1 .env 版本NestJs 直接可用# 服务端口 PORT3000 # MongoDB 8.0 MONGODB_URImongodb://localhost:27017/ai_chat # TaoToken 统一通道 TAOTOKEN_API_KEYsk-你的TaoToken密钥 TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELdeepseek-chat # CORS CORS_ORIGINhttp://localhost:5173,http://localhost:8080注意TAOTOKEN_BASE_URL结尾不要带/v1Langchain 的ChatOpenAI会自己拼接路径。这一点踩过坑多写一层/v1会变成/v1/v1/chat/completions直接 404。3.2 settings.json 版本{ server: { port: 3000, corsOrigin: [http://localhost:5173, http://localhost:8080] }, mongodb: { uri: mongodb://localhost:27017/ai_chat }, taotoken: { apiKey: sk-你的TaoToken密钥, baseUrl: https://taotoken.net/api, model: deepseek-chat, temperature: 0.7, maxTokens: 1024 } }3.3 config.toml 版本[server] port 3000 cors_origin [http://localhost:5173, http://localhost:8080] [mongodb] uri mongodb://localhost:27017/ai_chat [taotoken] api_key sk-你的TaoToken密钥 base_url https://taotoken.net/api model deepseek-chat temperature 0.7 max_tokens 1024三种格式字段含义一致核心就四个apiKey、baseUrl、model、生成参数。把模型凭证收敛到这一处后面换模型只改model字段。3.4 AiModule 配置骨架import { Module } from nestjs/common; import { ConfigModule } from nestjs/config; import { AiService } from ./ai.service; Module({ imports: [ConfigModule], providers: [AiService], exports: [AiService], }) export class AiModule {}AiService负责把配置转成 Langchain 的模型实例import { Injectable } from nestjs/common; import { ConfigService } from nestjs/config; import { ChatOpenAI } from langchain/openai; Injectable() export class AiService { private llm: ChatOpenAI; constructor(private readonly configService: ConfigService) { const apiKey this.configService.getstring(TAOTOKEN_API_KEY)!; const baseURL this.configService.getstring(TAOTOKEN_BASE_URL)!; const model this.configService.getstring(TAOTOKEN_MODEL)!; this.llm new ChatOpenAI({ apiKey, model, streaming: true, temperature: 0.7, maxTokens: 1024, configuration: { baseURL }, }); } async streamChat(prompt: string) { return this.llm.stream([[user, prompt]]); } }这里configuration.baseURL就是 TaoToken 通道的接入点。业务层完全不知道底层是 Deepseek 还是别的模型只调用streamChat。4. MongoDB 会话存储字段设计聊天助手要「记得住」就得把每轮对话落库。我设计了两张表Chat存消息明细HumanSession存会话状态AI 模式还是人工模式。这样后续要加人工客服接管不用改消息表结构。4.1 Chat 消息表import { Prop, Schema, SchemaFactory } from nestjs/mongoose; import { Document } from mongoose; export type ChatDocument Chat Document; Schema({ timestamps: true }) export class Chat { Prop({ required: true, index: true }) sessionId: string; Prop({ required: false }) userMessage: string; Prop({ required: false }) aiResponse: string; Prop({ default: false }) isHuman: boolean; } export const ChatSchema SchemaFactory.createForClass(Chat);字段说明sessionId建索引因为查询历史永远按会话过滤userMessage和aiResponse分开存方便前端左右气泡渲染isHuman标记这条回复是否来自人工为后续接管留口子timestamps自动生成createdAt排序历史时直接用它。4.2 HumanSession 会话状态表import { Prop, Schema, SchemaFactory } from nestjs/mongoose; import { Document } from mongoose; export type HumanSessionDocument HumanSession Document; Schema({ timestamps: true }) export class HumanSession { Prop({ required: true, unique: true }) sessionId: string; Prop({ default: ai }) status: ai | human; Prop({ default: null }) adminId: string; } export const HumanSessionSchema SchemaFactory.createForClass(HumanSession);status只有两个值ai表示正常走模型human表示已转人工。adminId记录接管的管理员方便审计。4.3 上下文拼装逻辑每次请求进来先按sessionId查历史按createdAt升序拼成对话文本再和当前问题一起塞进 promptasync getHistory(sessionId: string) { return this.chatModel.find({ sessionId }).sort({ createdAt: 1 }); } private buildHistoryText(docs: ChatDocument[]) { return docs .map((item) 用户${item.userMessage}\n助手${item.aiResponse}) .join(\n); }这里有个细节历史不能无限拼。Deepseek 的上下文窗口有限我一般只取最近 20 条或者按字符数截断到 6000 字以内。否则聊久了 token 消耗会失控。5. 端到端验证跑通带持久化上下文的聊天接口配置和存储都就位后用一个最小闭环验证发消息 → 模型流式返回 → 落库 → 再发一条能带上文。5.1 ChatService 核心流式逻辑import { Injectable } from nestjs/common; import { InjectModel } from nestjs/mongoose; import { Model } from mongoose; import { Observable } from rxjs; import { Chat, ChatDocument } from ../schemas/chat.schema; import { AiService } from ../ai/ai.service; Injectable() export class ChatService { constructor( InjectModel(Chat.name) private chatModel: ModelChatDocument, private aiService: AiService, ) {} streamMessage(sessionId: string, message: string): Observablestring { return new Observable((subscriber) { void (async () { try { const historyDocs await this.getHistory(sessionId); const history this.buildHistoryText(historyDocs); const prompt 以下是历史对话\n${history}\n用户现在问${message}; const stream await this.aiService.streamChat(prompt); let full ; for await (const chunk of stream) { if (typeof chunk.content string) { full chunk.content; subscriber.next(chunk.content); } } await this.chatModel.create({ sessionId, userMessage: message, aiResponse: full, isHuman: false, }); subscriber.complete(); } catch (err) { subscriber.error(err); } })(); }); } async getHistory(sessionId: string) { return this.chatModel.find({ sessionId }).sort({ createdAt: 1 }).limit(20); } private buildHistoryText(docs: ChatDocument[]) { return docs .map((item) 用户${item.userMessage}\n助手${item.aiResponse}) .join(\n); } }5.2 Controller 暴露 SSE 接口import { Controller, Get, Query, Res } from nestjs/common; import type { Response } from express; import { ChatService } from ./chat.service; Controller(chat) export class ChatController { constructor(private chatService: ChatService) {} Get(stream) stream( Query(sessionId) sessionId: string, Query(message) message: string, Res() res: Response, ) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); this.chatService.streamMessage(sessionId, message).subscribe({ next: (token) res.write(data: ${token}\n\n), complete: () { res.write(data: [DONE]\n\n); res.end(); }, error: () res.end(), }); } }5.3 验证动作启动服务npm run start:dev第一条请求问一个需要记忆的问题curl -N http://localhost:3000/chat/stream?sessionIdtest-001message我叫小明喜欢喝美式你应该看到 SSE 流式返回最后以data: [DONE]结束。接着第二条同一个sessionIdcurl -N http://localhost:3000/chat/stream?sessionIdtest-001message我刚才说我叫什么喜欢喝什么如果模型回答里出现「小明」和「美式」说明上下文持久化生效了。再去 MongoDB 里查一下mongosh use ai_chat db.chats.find({ sessionId: test-001 }).sort({ createdAt: 1 })应该能看到两条记录每条都有userMessage和aiResponse。到这一步带持久化上下文的聊天接口就跑通了。6. 本篇常见错排查6.1 401 Unauthorized最常见的原因是 Key 没读到。检查.env里变量名和configService.get的字符串是否完全一致大小写敏感。另一个原因是 Key 前后有空格复制时容易带上。可以在AiService构造里打印apiKey.slice(0, 6)确认前缀。6.2 404 Not Found八成是baseURL写错了。正确值是https://taotoken.net/api不要加/v1不要加结尾斜杠。Langchain 内部会拼/chat/completions。如果你用的是settings.json确认 JSON 里没有多余逗号导致解析失败。6.3 模型名不识别model字段要和 TaoToken 通道支持的名称一致。先用模型对话页面手动验证一次确认能正常返回再把模型名抄进配置。不要凭记忆写。6.4 上下文丢失如果第二条消息模型不记得上文先查 MongoDB 里有没有落库。没有记录说明chatModel.create没执行可能是流式循环里抛错被吞了。有记录但模型不记得检查getHistory的排序和limit以及buildHistoryText是否真的把历史拼进了 prompt。6.5 流式返回卡住不结束SSE 接口忘记res.end()或者subscriber.complete()没触发。检查for await循环是否正常退出。另外 NestJs 默认的响应超时也可能干扰长连接场景建议在 Controller 里显式设置 header。6.6 MongoDB 连接失败确认本地 MongoDB 8.0 已启动MONGODB_URI里的库名和端口正确。如果用了 Docker注意容器网络里localhost指向容器自身要换成宿主机地址或服务名。7. 下一步把统一 Key 用到 Coding 与 Agent 场景跑通这个聊天助手后你会发现 TaoToken 统一 Key 的价值不止在聊天。同一套凭证可以复用到代码补全、Agent 工具调用等场景。如果你打算把模型接进日常编码流程可以看看 Coding Plan它把模型调用和编码工作流结合得更紧https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan接入文档里有各语言和框架的对接示例NestJs 之外的服务也能参考https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc如果你在配置过程中遇到 401 或 404优先回到 API Keys 页面确认 Key 状态再对照接入文档检查 baseURLhttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys我自己的习惯是每接一个新模型先用模型对话页面发一条「你好」确认通道通再写进代码。这一步花 30 秒能省掉半小时的排查。配置骨架抄完之后先把sessionId固定成test-001跑两条 curl确认上下文和落库都正常再往前端接。这样出问题时你能立刻判断是模型通道的问题还是业务逻辑的问题。