DeepSeek 版 + 通义版 双轨整合 · NestJS 11 → LangChain.js → pgvector RAG → 综合项目上线
本手册由 tutorial-dsv4(DeepSeek 优先版,D 系列)与 tutorial-qw3.8(通义/新手友好版,Q 系列)两套教程合并而成, 同一门课:NestJS 11 + LangChain.js 1.x + PostgreSQL 16 / pgvector,从零搭一个可上线的 AI 知识库问答系统。
| 阶段 | 🐳 DeepSeek 版(D) | 🐫 通义版(Q) |
|---|---|---|
| 0 环境 | D0 前置环境搭建(专篇) | 并入各篇前置章节 |
| 1 NestJS 工程化 | D1 | Q1 |
| 2 LangChain 大模型服务 | D2 | Q2 |
| 3 pgvector 向量检索 RAG | D3 | Q3 |
| 4 综合实战上线 | D4 | Q4 |
| 附加 | D5 教程编写计划(决策全记录) | 每节固定七段结构:目标→原理→最小示例→完整实现→验证→坑点→练习 |
选版建议:纯新手先走 Q 版(讲解更细、统一课节结构);主力用 DeepSeek/字节模型或对决策过程感兴趣读 D 版(含 00 环境专篇与 plan 全案)。两版代码目录互相独立,均可运行。
📚 配套学习路线可交叉参阅:AI Agent 开发学习手册(171 天)
从零到一,用 NestJS + LangChain.js + PostgreSQL/pgvector 搭建一个完整可上线的 AI 知识库问答后端系统。
全部使用开源组件,大模型优先适配国产模型(DeepSeek、通义千问、字节 Seed),统一走 OpenAI 兼容协议接口。
| 阶段 | 主题 | 交付物 |
|---|---|---|
| 阶段 1 | NestJS 后端工程化全流程 | 01-nestjs-complete-guide.md + code/01-nest-basic/ |
| 阶段 2 | LangChain 大模型服务开发 | 02-langchain-ai-service.md + code/02-langchain-module/ |
| 阶段 3 | PostgreSQL 进阶与向量检索 RAG | 03-postgresql-vector-rag.md + code/03-pg-vector-rag/ |
| 阶段 4 | 综合实战:AI 知识库问答系统 | 04-full-project.md + code/04-full-project/ |
学习顺序:严格按 1 → 2 → 3 → 4 递进,每阶段独立可运行。前序阶段产出是后续的依赖骨架。
| 层 | 技术 | 版本 |
|---|---|---|
| 运行时 | Node.js | 22 LTS(LangChain 1.x 硬性要求) |
| 后端框架 | NestJS(Express 引擎) | 11.x |
| 语言 | TypeScript(strict,禁用 any) | 5.7+ |
| ORM | TypeORM | 0.3.x |
| 数据库 | PostgreSQL | 16.x |
| 向量插件 | pgvector | 0.8.x |
| 大模型框架 | LangChain.js | 1.x 最新线 |
| 大模型 | DeepSeek / 通义千问 / 字节 Seed | OpenAI 兼容协议 |
| 嵌入模型 | 通义千问 text-embedding-v3 | DashScope 兼容端点 |
先阅读 00-environment-setup.md,完成 Node 22、PostgreSQL 16、pgvector 的安装,并(可选)申请国产大模型 API Key。
以阶段 1 为例:
cd tutorial/code/01-nest-basic
cp .env.example .env # 按需修改数据库连接
npm install
npm run start:dev # 开发模式启动
启动后:
code/*/README.md说明:本教程大模型调用采用 Mock LLM 兜底,无 API Key 也能离线跑通全部链路;配置真实 Key 后一键切换到真实模型。
tutorial/
├── plan.md # 教程总纲(本计划)
├── README.md # 本文件
├── 00-environment-setup.md # 前置环境搭建
├── 01-nestjs-complete-guide.md # 阶段1文档
├── 02-langchain-ai-service.md # 阶段2文档
├── 03-postgresql-vector-rag.md # 阶段3文档
├── 04-full-project.md # 阶段4文档
└── code/
├── 01-nest-basic/
├── 02-langchain-module/
├── 03-pg-vector-rag/
└── 04-full-project/
| 阶段 | 业余 | 全职 |
|---|---|---|
| 阶段 1 | 1 周 | 3–4 天 |
| 阶段 2 | 1 周 | 4–5 天 |
| 阶段 3 | 5–7 天 | 3–4 天 |
| 阶段 4 | 1 周 | 4–5 天 |
| 合计 | 约 4 周 | 约 2 周 |
.call()/.run()/langchain/chains 写法已废弃,本教程统一 1.x 范式(.invoke()/.stream()/LCEL .pipe())。pg 原生 SQL 做距离检索。一套面向初学者的中文教程,从零搭建到完整上线一个 AI 知识库问答系统。全程配套可运行代码,每个知识点遵循统一结构:
🎯 学习目标 → 📖 原理讲解 → 🔨 最小示例 → 🏗️ 完整实现 → ✅ 验证步骤 → ⚠️ 常见坑点 → 📝 课后练习
阶段 1 NestJS 工程化 ──► 阶段 2 LangChain 服务化 ──► 阶段 3 向量检索与 RAG ──► 阶段 4 综合项目
(后端地基) (AI 能力层) (数据与检索层) (全部整合上线)
| 阶段 | 主题 | 文档 | 代码目录 | 建议时长(每天 2~3 小时) |
|---|---|---|---|---|
| 1 | NestJS 后端工程化全流程 | docs/01-nestjs-complete-guide.md | code/01-nest-basic/ | 4~5 天 |
| 2 | LangChain 大模型服务开发 | docs/02-langchain-ai-service.md | code/02-langchain-module/ | 4~5 天 |
| 3 | PostgreSQL 进阶与向量检索 RAG | docs/03-postgresql-vector-rag.md | code/03-pg-vector-rag/ | 3~4 天 |
| 4 | 综合实战:AI 知识库问答系统 | docs/04-full-project.md | code/04-full-project/ | 4~6 天 |
请严格按 1 → 2 → 3 → 4 的顺序学习。每个阶段项目独立可运行,学完当阶段即可单独启动验证。合计约 2~3 周;有 NestJS 经验者可压缩到 1~2 周。
# macOS
brew install node@22
# 或使用 nvm(推荐,便于多版本管理)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install 22 && nvm use 22
# 验证
node -v # >= v20
npm -v
方式 A(推荐):Docker 一键启动,镜像自带 pgvector 扩展,免去编译麻烦:
docker run -d --name tutorial-pg \
-e POSTGRES_USER=tutorial \
-e POSTGRES_PASSWORD=tutorial123 \
-e POSTGRES_DB=tutorial \
-p 5432:5432 \
pgvector/pgvector:pg16
方式 B:brew 本地安装(macOS):
brew install postgresql@16 pgvector
brew services start postgresql@16
方式 C:Linux 源码编译 pgvector(需先装 postgresql-server-dev-16、build-essential):
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
cd pgvector && make && sudo make install
验证 pgvector(任一方式安装后执行):
# 进入 psql(Docker 方式)
docker exec -it tutorial-pg psql -U tutorial -d tutorial
# 在 psql 中执行
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 输出版本号(如 0.8.0)即安装成功
\q
npm i -g @nestjs/cli
nest -v # 应输出 11.x
本教程统一走 OpenAI 兼容协议,以下任一平台均可(按自己情况选一个即可):
| 平台 | Base URL | 推荐模型 |
|---|---|---|
| DeepSeek | https://api.deepseek.com |
deepseek-chat / deepseek-reasoner |
| 阿里云百炼(通义千问) | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-plus / qwen-turbo / text-embedding-v3 |
| 火山方舟(字节 Seed) | https://ark.cn-beijing.volces.com/api/v3 |
doubao 系列 |
没有 API Key 也能学:阶段 3 的向量检索支持本地 Embedding 模型离线跑通,详见各阶段 README。
从 docker.com 安装 Docker Desktop 即可。
Q:5432 端口被占用?
# 查看占用进程
lsof -i :5432
# 或让 Docker 容器改用其他端口:-p 5433:5432,同时修改项目 .env 中 DB_PORT=5433
Q:brew 安装的 postgresql 版本冲突?
brew services stop postgresql@14 # 停掉旧版本
brew services start postgresql@16
Q:各阶段项目端口是多少?
为避免同时运行时冲突,四个项目默认端口已错开,均可通过 .env 的 PORT 覆盖:
| 项目 | 默认端口 |
|---|---|
| 01-nest-basic | 3101 |
| 02-langchain-module | 3102 |
| 03-pg-vector-rag | 3103 |
| 04-full-project | 3104 |
tutorial/
├── README.md # 本文件:学习路径与环境安装
├── docs/ # 四个阶段的教程文档
│ ├── 01-nestjs-complete-guide.md
│ ├── 02-langchain-ai-service.md
│ ├── 03-postgresql-vector-rag.md
│ └── 04-full-project.md
└── code/ # 四个阶段的配套代码(各自独立可运行)
├── 01-nest-basic/
├── 02-langchain-module/
├── 03-pg-vector-rag/
└── 04-full-project/
# 以阶段 1 为例
cd code/01-nest-basic
cp .env.example .env # 按需修改数据库连接
npm install
npm run start:dev # 启动开发服务器
# 另开终端,按 README 中的 curl 命令逐个验证接口
祝学习顺利!遇到报错不要慌——每章的"常见坑点"小节都列出了原始报错信息与解决方法。
本教程四个阶段的代码都要依赖以下环境。请先按本文完成安装,再进入各阶段。
为什么必须 22+:LangChain.js 1.x 已放弃 Node 18(Node 18 于 2025 年 3 月 EOL),安装或运行低于 22 的版本会直接报错。本教程统一使用 Node 22 LTS。
# 安装 nvm(若已安装可跳过)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
# 重新加载 shell 配置
source ~/.zshrc
# 安装并启用 Node 22
nvm install 22
nvm use 22
nvm alias default 22
node -v # 期望 v22.x.x
npm -v # 期望 10.x
若
node -v显示的不是 22,用nvm use 22切换后再继续。
brew install postgresql@16
brew services start postgresql@16 # 开机自启并立即启动
注意:
postgresql@16是 keg-only 安装,默认不会软链到PATH。如需在终端直接使用psql,执行:echo 'export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"' >> ~/.zshrc source ~/.zshrc
sudo apt update
sudo apt install -y postgresql-16
sudo systemctl enable postgresql
sudo systemctl start postgresql
# 进入 psql(macOS 默认超级用户是你的系统用户名)
psql postgres
# 在 psql 内执行:
CREATE USER tutorial WITH PASSWORD 'tutorial123';
CREATE DATABASE tutorial OWNER tutorial;
\q
教程所有阶段的
.env默认连接串:postgres://tutorial:tutorial123@localhost:5432/tutorial请按你实际创建的用户名/密码/库名修改.env。
pgvector 让 PostgreSQL 支持向量存储与相似度检索,是阶段 3 / 4 的核心。
brew install pgvector
⚠️ macOS + PostgreSQL 16 特别坑:新版 Homebrew 的 pgvector bottle(如 0.8.6)只预编译了 PostgreSQL 17/18 的扩展文件,不再包含 PG16。如果你的 PostgreSQL 是 16,
CREATE EXTENSION vector会报extension "vector" is not available。此时必须源码编译:
brew install git make # 确保有编译工具(Xcode CLT)
git clone --depth 1 --branch v0.8.6 https://github.com/pgvector/pgvector.git
cd pgvector
make PG_CONFIG=/opt/homebrew/opt/postgresql@16/bin/pg_config
make PG_CONFIG=/opt/homebrew/opt/postgresql@16/bin/pg_config install
sudo apt install -y postgresql-16-pgvector
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
cd pgvector
make
make install # 需要 postgresql-server-dev(Linux)或 Xcode CLT(macOS)
CREATE EXTENSION vector需要超级用户权限(会安装 C 函数到 pg_catalog)。macOS 上 Homebrew 安装的超级用户是你的系统用户名,直接psql -d tutorial连接即可。
# macOS:直接用系统超级用户连接(无需 -U)
psql -d tutorial
# Linux:用 postgres 超级用户
# sudo -u postgres psql -d tutorial
在 psql 内执行:
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion FROM pg_extension WHERE extname = 'vector'; -- 期望返回 0.8.x
\q
| 现象 | 原因 | 解决 |
|---|---|---|
ERROR: extension "vector" has no installation script |
pgvector 未安装成功 | 重装 pgvector,确认 pg_config 指向 PostgreSQL 16 |
ERROR: could not open extension control file |
pgvector 与 PG 版本不匹配 | 用与 PostgreSQL 同版本的 pgvector 重新编译 |
brew install pgvector 装到了别的 PG 版本 |
存在多个 PG 版本 | 用源码编译并 make PG_CONFIG=/opt/homebrew/opt/postgresql@16/bin/pg_config install |
本教程大模型调用用 Mock 兜底:不配置任何 Key 也能离线跑通全部链路。要体验真实大模型效果,任选一家申请:
| 厂商 | 申请入口 | OpenAI 兼容 baseURL | 常用模型 |
|---|---|---|---|
| DeepSeek | https://platform.deepseek.com | https://api.deepseek.com/v1 |
deepseek-chat、deepseek-reasoner |
| 阿里云通义千问 | https://dashscope.console.aliyun.com | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-turbo、qwen-plus、qwen-max |
| 字节火山方舟 | https://console.volcengine.com/ark | https://ark.cn-beijing.volces.com/api/v3 |
doubao-*、deepseek-* 端点 |
重要:DeepSeek 官方不提供 embedding(向量化)接口,因此本教程的向量化统一使用通义千问 text-embedding-v3(与聊天模型共用 DashScope 兼容端点)。配置 .env 时注意聊天模型与嵌入模型分开配置:
# 聊天模型(示例:DeepSeek)
LLM_PROVIDER=deepseek
DEEPSEEK_API_KEY=sk-xxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# 嵌入模型(通义千问)
EMBEDDING_API_KEY=sk-xxxx
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDING_MODEL=text-embedding-v3
真实调用会产生流量费用,请留意各家计费规则。
依次执行,全部通过即可开始学习:
node -v # v22.x.x
psql --version # PostgreSQL 16.x
psql -U tutorial -d tutorial -c "SELECT extversion FROM pg_extension WHERE extname='vector';" # 0.8.0
三项全部符合预期,即可进入 阶段 1:NestJS 后端工程化。
配套代码:
code/01-nest-basic/(完整可运行项目) 本章覆盖后端开发最常用的 9 大能力,每个知识点按「原理 → 代码 → 验证」展开。
NestJS 通过官方 CLI(@nestjs/cli)脚手架项目。CLI 底层用 Webpack 打包,提供 generate(生成模块/控制器/服务)、build、start 等命令,让开发者专注于业务而非配置。
# 全局安装 CLI(用 npm 或你偏好的包管理器)
npm i -g @nestjs/cli
# 创建项目(nest new 项目名)
nest new 01-nest-basic
# 选择 npm 作为包管理器,等待安装完成
| 命令 | 说明 |
|---|---|
npm run start:dev |
开发模式(监听文件变化自动重启,最常用) |
npm run start:debug |
调试模式(可断点) |
npm run start:prod |
生产模式(先 build 再运行) |
npm run build |
编译到 dist/ |
npm run lint |
ESLint 检查 |
npm run test / test:e2e |
单元 / E2E 测试 |
01-nest-basic/
├── package.json # 依赖与脚本声明
├── tsconfig.json # TS 编译配置(含 decorator 支持)
├── tsconfig.build.json # 构建用(排除测试文件)
├── nest-cli.json # Nest CLI 配置(sourceRoot 等)
├── .eslintrc.js / .prettierrc# 代码规范
├── .env.example # 环境变量模板(需复制为 .env)
└── src/
├── main.ts # 应用入口:装配全局管道/过滤器/Swagger/端口
├── app.module.ts # 根模块:聚合所有功能模块
├── common/ # 通用能力:守卫/拦截器/过滤器/装饰器/中间件
├── config/ # 多环境配置与校验
├── entities/ # TypeORM 实体
├── migrations/ # 数据库迁移脚本
└── modules/ # 业务功能模块(auth/users/upload/chat/health)
├── auth/ # 鉴权模块
├── users/ # 用户 CRUD 模块
├── upload/ # 文件上传下载模块
├── chat/ # WebSocket 网关模块
└── health/ # 健康检查模块
关键文件作用:
| 文件 | 作用 |
|---|---|
main.ts |
创建 NestFactory 应用实例,注册全局中间件/管道/过滤器,监听端口 |
app.module.ts |
根模块 @Module,通过 imports 挂载各功能模块,装配 TypeORM 连接 |
*.module.ts |
模块定义:声明该模块的 controller/provider,导入依赖模块 |
*.controller.ts |
路由层:处理 HTTP 请求、参数解析、返回响应 |
*.service.ts |
业务层:被 Controller 注入调用,承载业务逻辑 |
*.entity.ts |
TypeORM 实体:映射数据库表 |
*.dto.ts |
数据传输对象:定义入参结构 + 校验规则 |
@Module 装饰器组织代码,是依赖注入的边界。每个功能放一个模块,imports 引入依赖、controllers 声明路由、providers 声明可注入对象、exports 对外暴露。// users.module.ts —— 模块定义
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from '../../entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])], // 注册实体,注入 Repository
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // 供 auth 模块复用
})
export class UsersModule {}
// users.service.ts —— 通过构造函数注入 Repository
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.userRepo.find();
}
}
npm run start:dev
# 另开终端:
curl http://localhost:3000/users
@nestjs/config 封装 dotenv,ConfigModule.forRoot() 按 NODE_ENV 加载不同 .env 文件(.env.development / .env.test / .env.production)。用 joi 或 class-validator 对配置做启动期校验,配置缺失/非法时立即报错,避免运行时才暴露问题。
// config/configuration.ts —— 命名空间化配置
export default () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
database: {
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
username: process.env.DB_USER ?? 'tutorial',
password: process.env.DB_PASSWORD ?? 'tutorial123',
name: process.env.DB_NAME ?? 'tutorial',
},
jwt: {
secret: process.env.JWT_SECRET ?? 'dev-secret',
accessTtl: process.env.JWT_ACCESS_TTL ?? '15m',
refreshTtl: process.env.JWT_REFRESH_TTL ?? '7d',
},
});
// config/env.validation.ts —— Joi 校验
import * as Joi from 'joi';
export const validationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
PORT: Joi.number().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_USER: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
});
// app.module.ts —— 装配
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
validationSchema,
envFilePath: [`.env.${process.env.NODE_ENV ?? 'development'}`, '.env'],
}),
cp .env.example .env
npm run start:dev # 若 DB_PASSWORD 缺失,启动即报错
TypeORM 是 TS 生态最成熟的 ORM,通过装饰器把类映射成表,Repository 提供类型安全的 CRUD。@nestjs/typeorm 将其接入 Nest 的 DI 容器,TypeOrmModule.forFeature([Entity]) 后即可 @InjectRepository 注入。
// entities/user.entity.ts
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, length: 100 })
email: string;
@Column({ select: false }) // 默认查询不返回密码
password: string;
@Column({ length: 50, nullable: true })
nickname: string;
@Column({ type: 'varchar', array: true, default: '{}' })
roles: string[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
// Repository CRUD
await this.userRepo.save(this.userRepo.create(dto)); // 增
await this.userRepo.find(); // 查全部
await this.userRepo.findOneBy({ id }); // 查单个
await this.userRepo.update(id, dto); // 改
await this.userRepo.delete(id); // 删
// 事务:dataSource.transaction
await this.dataSource.transaction(async (manager) => {
const user = await manager.save(User, userData);
await manager.save(Profile, { userId: user.id, ...profileData });
});
# 迁移脚本(package.json 中配置了 typeorm 脚本)
npm run migration:generate -- src/migrations/Init # 根据实体生成迁移
npm run migration:run # 执行迁移
npm run migration:revert # 回滚
curl -X POST http://localhost:3000/users -H 'Content-Type: application/json' \
-d '{"email":"a@b.com","password":"123456","nickname":"张三"}'
curl http://localhost:3000/users
登录成功后签发 Access Token(短期,用于接口鉴权)+ Refresh Token(长期,用于换发新 Access)。JwtStrategy 校验 Access Token 并解析出用户信息挂到 request.user,JwtAuthGuard 拦截未携带有效 Token 的请求。@Roles 装饰器 + RolesGuard 实现接口级权限控制。
// strategies/jwt.strategy.ts —— 校验 Access Token
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>('jwt.secret'),
});
}
async validate(payload: JwtPayload) {
return { id: payload.sub, email: payload.email, roles: payload.roles };
}
}
// common/guards/jwt-auth.guard.ts —— 复用 Passport 守卫
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// common/decorators/public.decorator.ts —— 标记公开接口
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// 使用:登录接口公开,用户列表需鉴权,删除需 admin 角色
@Public()
@Post('login')
login(@Body() dto: LoginDto) { ... }
@Get('profile')
getProfile(@Req() req) { return req.user; } // 需登录
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) { ... } // 需 admin 角色
# 注册
curl -X POST http://localhost:3000/auth/register -H 'Content-Type: application/json' \
-d '{"email":"a@b.com","password":"123456","nickname":"张三"}'
# 登录,获取 accessToken / refreshToken
curl -X POST http://localhost:3000/auth/login -H 'Content-Type: application/json' \
-d '{"email":"a@b.com","password":"123456"}'
# 刷新
curl -X POST http://localhost:3000/auth/refresh -H 'Content-Type: application/json' \
-d '{"refreshToken":"<上一步的 refreshToken>"}'
# 带 Token 访问受保护接口
curl http://localhost:3000/auth/profile -H 'Authorization: Bearer <accessToken>'
| 组件 | 时机 | 典型用途 |
|---|---|---|
| 中间件 Middleware | 路由处理之前 | 日志、跨域、鉴权前置、请求预处理 |
| 管道 Pipe | 参数交给 Controller 之前 | 数据转换、校验(ValidationPipe) |
| 拦截器 Interceptor | 处理前后(AOP) | 统一响应格式、性能计时、缓存 |
| 异常过滤器 Filter | 抛异常之后 | 统一错误响应结构 |
执行顺序:Middleware → Guard → Interceptor(前) → Pipe → Controller → Interceptor(后) → Filter(若异常)。
// common/middleware/logger.middleware.ts —— 记录请求耗时
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () =>
console.log(`[${req.method}] ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`),
);
next();
}
}
// common/interceptors/transform.interceptor.ts —— 统一响应 { code, data, message }
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => ({ code: 0, data, message: 'ok' })));
}
}
// common/filters/http-exception.filter.ts —— 统一错误结构
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
res.status(status).json({ code: status, data: null, message: /* 提取错误信息 */ });
}
}
// main.ts 装配
app.use(new LoggerMiddleware().use.bind(new LoggerMiddleware())); // 或 app.use(logger)
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalFilters(new AllExceptionsFilter());
# 观察日志中间件输出
curl http://localhost:3000/health
# 触发校验错误(缺少必填字段),观察统一错误结构
curl -X POST http://localhost:3000/users -H 'Content-Type: application/json' -d '{}'
Nest 内置 multer。@UploadedFile 配合 FileInterceptor 接收单文件,FilesInterceptor 接收多文件。用 MulterModule 或拦截器的 limits/fileFilter 限制大小与类型。下载/静态资源用 @nestjs/serve-static 的 ServeStaticModule。
// upload.controller.ts
@Post('upload')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => { // 仅允许图片
if (!file.mimetype.match(/\/(jpeg|png|jpg|gif|webp)$/)) {
return cb(new BadRequestException('仅支持图片文件'), false);
}
cb(null, true);
},
}))
upload(@UploadedFile() file: Express.Multer.File) {
return { filename: file.filename, originalname: file.originalname, size: file.size, url: `/static/${file.filename}` };
}
// app.module.ts —— 静态资源服务
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/static',
}),
# 生成测试图并上传
curl -X POST http://localhost:3000/upload/upload -F "file=@./test.png"
# 访问返回的 url 即可下载/预览
curl -O http://localhost:3000/static/<filename>
@nestjs/websockets + socket.io 提供 @WebSocketGateway 网关。服务端用 @SubscribeMessage 监听事件,用 @WebSocketServer 拿到 server 实例做房间管理与广播。握手时校验 Token 实现鉴权。
@WebSocketGateway({ cors: { origin: '*' }, namespace: '/chat' })
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer() server: Server;
// 握手鉴权
async handleConnection(client: Socket) {
const token = client.handshake.auth?.token ?? client.handshake.headers.authorization;
if (!token || !this.jwtService.verify(token)) client.disconnect();
}
@SubscribeMessage('joinRoom')
joinRoom(@MessageBody('room') room: string, @ConnectedSocket() client: Socket) {
client.join(room);
return { event: 'joined', data: room };
}
@SubscribeMessage('sendMessage')
sendMessage(@MessageBody() payload: { room: string; message: string }, @ConnectedSocket() client: Socket) {
this.server.to(payload.room).emit('newMessage', { from: client.id, message: payload.message });
}
}
# 用 socket.io 客户端或 curl 探测(生产可用 wscat)
npm i -g wscat
wscat -c "ws://localhost:3000/chat" --auth "Bearer <token>"
# 发送:{"event":"joinRoom","data":{"room":"room1"}}
@nestjs/swagger 用装饰器(@ApiTags/@ApiOperation/@ApiProperty/@ApiBearerAuth)从代码自动生成 OpenAPI 文档,SwaggerModule 挂载到路由。
// main.ts
const config = new DocumentBuilder()
.setTitle('NestJS 基础项目 API')
.setDescription('阶段1完整示例')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// dto 示例
export class RegisterDto {
@ApiProperty({ example: 'a@b.com' })
@IsEmail()
email: string;
@ApiProperty({ example: '123456', minLength: 6 })
@MinLength(6)
password: string;
}
启动后访问 http://localhost:3000/api-docs 即可查看并在线调试全部接口。
完整启动说明、.env 配置、以及覆盖全部接口的 curl 命令清单,见 code/01-nest-basic/README.md。
01-nest-basic/
.env
.env.example
.prettierrc
README.md
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
src/
app.module.ts
main.ts
common/
config/
configuration.ts
data-source.ts
env.validation.ts
entities/
user.entity.ts
modules/配套代码:
code/02-langchain-module/(完整可运行模块) 在 NestJS 框架内模块化集成 LangChain.js,覆盖大模型后端开发的全部常用能力。
本教程使用 LangChain.js 1.x 最新稳定线(@langchain/core ^1.2、@langchain/openai ^1.5、langchain ^1.5)。它相比旧版(0.x)有重大变化,网上大量旧资料已失效:
| 旧写法(0.x,已废弃) | 1.x 写法 |
|---|---|
import { ... } from "langchain/chains" |
旧 chains 迁至 @langchain/classic |
model.call() / model.predict() |
model.invoke() |
ChatPromptTemplate.fromPromptMessages() |
ChatPromptTemplate.fromMessages() |
Runnable.bind() |
.bindTools() 等专用方法 |
retriever getRelevantDocuments() |
.invoke() |
| Node 18 可运行 | 要求 Node 22+ |
包结构:核心抽象在 @langchain/core;OpenAI 兼容模型在 @langchain/openai;Agent 运行时在 langchain + @langchain/langgraph。
LangChain 的核心抽象:
BaseChatModel,统一 .invoke() / .stream() 接口。SystemMessage / HumanMessage / AIMessage / ToolMessage,从 @langchain/core/messages 导入。ChatPromptTemplate.fromMessages()。.pipe() 组合成 Runnable 链。tool() 工厂 + zod schema 定义结构化入参。在 Nest 中,用 Provider 封装模型服务,通过依赖注入供各模块使用,天然契合 DI 体系。
// llm/model-factory.service.ts —— 用 ChatOpenAI 接入国产模型
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
model: 'deepseek-chat',
apiKey: 'sk-xxx',
configuration: { baseURL: 'https://api.deepseek.com/v1' }, // 关键:指向国产 OpenAI 兼容端点
temperature: 0.7,
});
核心要点:国产大模型(DeepSeek / 通义千问 / 字节 Seed)均提供 OpenAI 兼容接口,只需把
configuration.baseURL指到对应端点即可,无需额外适配包。
三个端点:
| 厂商 | baseURL | 模型名示例 |
|---|---|---|
| DeepSeek | https://api.deepseek.com/v1 |
deepseek-chat / deepseek-reasoner |
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-turbo / qwen-plus / qwen-max |
| 字节方舟 | https://ark.cn-beijing.volces.com/api/v3 |
doubao-* / deepseek-* |
用 模型工厂(Factory)+ 注册表 模式:工厂统一创建、缓存各 provider 模型实例,对外暴露统一的 getModel(name),实现「同时加载多个国产模型 + 运行时热切换」。
// llm/model-factory.service.ts
getModel(name: ModelProviderName, params?: ModelParams): BaseChatModel {
if (this.config.get<boolean>('llm.mock', true)) {
return new MockChatModel(`${name}:${params?.model ?? 'default'}`); // Mock 兜底
}
const provider = this.config.get<ProviderConfig>(`llm.providers.${name}`, ...);
return new ChatOpenAI({
model: params?.model ?? provider.model,
apiKey: provider.apiKey,
configuration: { baseURL: provider.baseURL },
...
});
}
ModelProviderName = 'deepseek' | 'qwen' | 'ark'。Mock 模式通过环境变量 LLM_MOCK=true 切换,无任何 key 也能离线跑通。
# 显式指定 provider
curl -X POST http://localhost:3001/chat -H 'Content-Type: application/json' \
-d '{"message":"你好","provider":"deepseek"}'
# 响应 route.reason 为「用户显式指定 provider」
| 参数 | 作用 | 建议 |
|---|---|---|
temperature |
采样温度(0-2),越高越随机 | 创意任务 0.8-1.2;事实问答 0-0.3 |
maxTokens |
最大输出 token 数 | 按任务设上限,防超长/超费 |
topP |
核采样,取累积概率前 p 的 token | 与 temperature 二选一调,勿同时大调 |
frequencyPenalty |
降低重复词概率 | 生成长文时 0.3-0.5 |
presencePenalty |
鼓励引入新词 | 需要多样性时调高 |
stop |
停止序列 | 结构化输出时用 ["\n\n"] 等 |
seed |
随机种子(部分模型支持) | 需要结果可复现时设置 |
thinking_mode |
推理模式(deepseek-reasoner 等) | 复杂推理开启,会先输出思考链 |
这些参数在请求时通过 ChatOpenAI 构造或 .withConfig() 传入,最终映射到各家 OpenAI 兼容 API 的 temperature、max_tokens、top_p 等字段。
请求 DTO 携带可覆盖参数,服务层把它们透传给模型工厂。这样同一个接口能按需调整模型行为,也支持「场景预设配置」。
// llm/dto/chat.dto.ts —— 可覆盖参数
export class ChatDto {
message: string;
conversationId?: string;
provider?: 'deepseek' | 'qwen' | 'ark';
strategy?: 'auto' | 'simple' | 'medium' | 'complex';
@Min(0) @Max(2) temperature?: number;
@Min(1) maxTokens?: number;
@Min(0) @Max(1) topP?: number;
}
// chat/chat.service.ts —— 动态参数透传
this.modelFactory.getModel(route.provider, {
model: route.model,
temperature: dto.temperature,
maxTokens: dto.maxTokens,
topP: dto.topP,
});
# 指定温度与 maxTokens
curl -X POST http://localhost:3001/chat -H 'Content-Type: application/json' \
-d '{"message":"写一首诗","temperature":1.2,"maxTokens":200}'
根据任务复杂度 / 成本自动选择模型:简单问题用轻量模型(省成本、低延迟),复杂问题用强模型(保质量)。核心是一个 route() 决策函数。
// llm/model-router.service.ts
route(input): RouteDecision {
if (input.provider) return { provider: input.provider, reason: '用户显式指定 provider' };
const complexity = input.complexity ?? this.detectComplexity(input.message ?? '');
switch (complexity) {
case 'simple': return { provider: 'qwen', model: 'qwen-turbo', ... };
case 'medium': return { provider: 'qwen', model: 'qwen-plus', ... };
case 'complex': return { provider: 'deepseek', model: 'deepseek-reasoner', ... };
}
}
复杂度检测用「长度 + 关键词」启发式:消息含「分析/对比/方案/代码」等词或超 100 字判为 complex。
curl -X POST http://localhost:3001/chat -H 'Content-Type: application/json' -d '{"message":"你好"}'
# route: qwen / qwen-turbo(简单)
curl -X POST http://localhost:3001/chat -H 'Content-Type: application/json' \
-d '{"message":"请详细分析对比微服务和单体架构优缺点并给出设计方案"}'
# route: deepseek / deepseek-reasoner(复杂)
MCP(Model Context Protocol)是让 AI 应用连接外部工具/数据的开放协议,三要素:
本教程用 @modelcontextprotocol/sdk 开发自定义 MCP Server,通过 Streamable HTTP 暴露。
// mcp/mcp.service.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
async handleRequest(req, res, parsedBody) {
const server = new McpServer({ name: 'tutorial-mcp-server', version: '1.0.0' });
server.registerTool('echo', {
title: 'Echo 回显',
description: '回显输入的文本',
inputSchema: { text: z.string() },
}, async (input) => ({ content: [{ type: 'text', text: `echo: ${input.text}` }] }));
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
await server.connect(transport);
await transport.handleRequest(req, res, parsedBody);
}
接入第三方 MCP:用
@langchain/mcp-adapters的MultiServerMCPClient连接远程 MCP Server,把其工具加载为 LangChain Tool,再bindTools挂载给模型。
curl -X POST http://localhost:3001/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# 返回 serverInfo: tutorial-mcp-server,capabilities.tools.listChanged=true
Skill 是「封装了业务逻辑、可被大模型调用的工具」。用 tool() 工厂 + zod schema 定义入参,模型通过 Function Calling 结构化调用。Skill 注册表统一管理,按需挂载。
// skills/skills/calculator.skill.ts
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
toTool(): StructuredToolInterface {
return tool(
async (input) => String(this.safeEval(input.expression)),
{
name: 'calculator',
description: '计算数学表达式,支持加减乘除与括号',
schema: z.object({ expression: z.string().describe('数学表达式') }),
},
);
}
// skills/skills.registry.ts —— 注册表
listTools(): StructuredToolInterface[] {
return [this.calculator.toTool(), this.databaseQuery.toTool()];
}
工具的真实调用需真实模型(Mock 模型不发起 Function Calling)。配置真实 key 后,提问「1+2*3 等于多少」即可看到模型调用 calculator 工具。
让大模型通过工具调用本地 Nest 业务接口 / 数据库,形成「模型 → 工具 → 本地数据 → 模型」的闭环。实现要点是 agent loop:
tool_callsToolMessage 回传给模型// skills/skills/database-query.skill.ts —— 调用本地数据库
toTool(): StructuredToolInterface {
return tool(
async (input) => {
const count = await this.messageRepo.countBy({ conversationId: input.conversationId });
return `会话 ${input.conversationId} 当前共有 ${count} 条历史消息`;
},
{ name: 'count_messages', description: '查询某个会话的历史消息数量', schema: z.object({ conversationId: z.string() }) },
);
}
// chat/chat.service.ts —— agent loop
for (let i = 0; i < maxIterations; i++) {
const result = await model.invoke(messages);
messages = [...messages, result];
if (!result.tool_calls || result.tool_calls.length === 0) { reply = ...; break; }
const toolMessages = await this.executeTools(result.tool_calls); // 执行工具
messages = [...messages, ...toolMessages];
}
多轮对话需要把历史消息持久化(PostgreSQL),并在每轮请求时组装进上下文。当历史过长时需做 Token 估算 / 截断 / 压缩,长时记忆可用摘要或向量记忆(阶段 3 深入)。
// context/context.service.ts
async append(conversationId, role, content) { /* 落库 */ }
async getHistory(conversationId, limit = 20) { /* 按时间正序读取 */ }
estimateTokens(text: string): number {
const cn = (text.match(/[\u4e00-\u9fa5]/g) ?? []).length;
return Math.ceil(cn / 1.5 + (text.length - cn) / 4); // 中文约 1.5 字/token
}
trimToBudget(messages, budgetTokens) { /* 从最新往前保留,直到接近预算 */ }
curl -X POST http://localhost:3001/chat -H 'Content-Type: application/json' -d '{"message":"第一轮"}'
# 记下返回的 conversationId,再发第二轮:
curl -X POST http://localhost:3001/chat -H 'Content-Type: application/json' \
-d '{"message":"第二轮","conversationId":"<conversationId>"}'
# 数据库 messages 表中可见 user/assistant 多轮记录
model.stream() 返回逐 token 的异步迭代器。Nest 用 @Sse() 装饰器把 Observable 转成 SSE 流;WebSocket 网关则逐 chunk 推送给客户端。
// chat/chat.service.ts —— 流式生成器
async *streamChat(dto: ChatDto): AsyncGenerator<string> {
const stream = await model.stream(messages);
for await (const chunk of stream) {
const text = this.contentToText(chunk.content);
if (text) { full += text; yield text; }
}
await this.context.append(conversationId, 'assistant', full);
}
// chat/chat.controller.ts —— SSE
@Post('stream') @Sse()
stream(@Body() dto: ChatDto): Observable<SseMessage> {
return new Observable((subscriber) => {
void (async () => {
for await (const text of this.chatService.streamChat(dto)) {
subscriber.next({ data: text });
}
subscriber.next({ data: '[DONE]' });
subscriber.complete();
})();
});
}
curl -N -X POST http://localhost:3001/chat/stream -H 'Content-Type: application/json' \
-d '{"message":"介绍一下你自己"}'
# 逐字输出 SSE 事件
# WebSocket(wscat)
wscat -c "ws://localhost:3001/chat-ws"
# 发送 {"event":"chat","data":{"message":"你好"}},收到逐 chunk 的 chunk 事件
完整启动说明、.env 配置、Mock/真实 key 切换方式,见 code/02-langchain-module/README.md。
| 现象 | 原因 | 解决 |
|---|---|---|
bindTools is not a function |
自定义模型未实现 bindTools |
Mock 模型补 bindTools 返回自身 |
TS2589 类型实例化过深 |
MCP SDK 1.30 + zod 类型缺陷 | @ts-ignore 精确忽略(已在代码中处理) |
| Node 18 下安装失败 | LangChain 1.x 要求 Node 22+ | 升级 Node |
| Mock 模型不调用工具 | Mock 不发起 Function Calling | 配置真实 key 验证工具闭环 |
02-langchain-module/
.env
.env.example
.prettierrc
README.md
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
src/
app.module.ts
main.ts
chat/
chat.controller.ts
chat.gateway.ts
chat.module.ts
chat.service.ts
config/
configuration.ts
env.validation.ts
context/
context.module.ts
context.service.ts
entities/
message.entity.ts
llm/
llm.module.ts
mock-chat-model.ts
model-factory.service.ts
model-router.service.ts
types.ts
mcp/
mcp.controller.ts
mcp.module.ts
mcp.service.ts
skills/
skills.module.ts
skills.registry.ts配套代码:
code/03-pg-vector-rag/(完整可运行项目) 从基础 CRUD 到 pgvector 向量插件、再到 RAG 全流程,结合大模型做知识增强。
阶段 1 已讲 TypeORM CRUD。本章进阶:复杂查询(QueryBuilder)、事务、索引优化。
// QueryBuilder 复杂查询
await this.docRepo
.createQueryBuilder('doc')
.where('doc.status = :status', { status: 'done' })
.orderBy('doc.createdAt', 'DESC')
.getMany();
// 事务:保证「删文档 + 删分片」原子性
await this.dataSource.transaction(async (manager) => {
await manager.delete(Chunk, { documentId: id });
await manager.delete(DocumentEntity, { id });
});
索引优化要点:高频过滤/排序字段建索引(如 documentId、createdAt),用 @Index() 装饰器或迁移。
# macOS
brew install pgvector
# 若 brew bottle 不含 PG16(新版已不含),需源码编译:
git clone --depth 1 --branch v0.8.6 https://github.com/pgvector/pgvector.git
cd pgvector && make PG_CONFIG=/opt/homebrew/opt/postgresql@16/bin/pg_config
make PG_CONFIG=/opt/homebrew/opt/postgresql@16/bin/pg_config install
# Linux
sudo apt install postgresql-16-pgvector
-- 在数据库中启用(需超级用户)
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion FROM pg_extension WHERE extname='vector'; -- 0.8.x
| 算子 | 含义 | 说明 |
|---|---|---|
<-> |
L2 欧氏距离 | 距离越小越相似 |
<=> |
余弦距离 | 文本语义检索最常用 |
<#> |
内积(取负) | 需向量已归一化 |
余弦相似度 = 1 - (a <=> b)。
-- HNSW:查询快,适合高频查询
CREATE INDEX chunks_embedding_hnsw_idx
ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- IVFFlat:构建快,适合大数据量批量场景
CREATE INDEX chunks_embedding_ivfflat_idx
ON chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
关键坑:HNSW 索引要求向量列有固定维度(
vector(1024)),无维度vector会报column does not have dimensions。TypeORM 里@Column({ type: 'vector' })生成的是无维度列,需用 SQL 固定维度:
ALTER TABLE chunks ALTER COLUMN embedding TYPE vector(1024);
分片质量直接决定 RAG 效果。本教程实现递归字符切分 + 重叠(documents/splitter.service.ts):
split(text, { chunkSize = 500, chunkOverlap = 50, separators = ['\n\n', '\n', '。', '!', '?', ';', '. ', ' ', ''] })
原理:按分隔符优先级(段落 → 换行 → 句子 → 空格 → 字符)逐级切,直到片段不超过 chunkSize;合并时保留 chunkOverlap 重叠,避免语义被硬切断。
// embedding/embedding.service.ts —— 统一入口,mock/真实可切换
async embed(texts: string[]): Promise<number[][]> {
if (this.isMock()) return texts.map((t) => mockEmbedding(t, this.dim()));
return this.getEmbeddings().embedDocuments(texts); // 通义千问 text-embedding-v3
}
重要:DeepSeek 官方无 embedding 接口,向量化统一用通义千问
text-embedding-v3(默认 1024 维)。Mock 模式用确定性哈希向量,无 key 离线可跑。
// 写入:embedding 存 `[0.1,0.2,...]` 字符串
await manager.createQueryBuilder().insert().into(Chunk).values(chunkRows).execute();
// 检索:raw SQL 余弦相似度 top-k
const rows = await this.dataSource.query(
`SELECT id, "documentId", content, 1 - (embedding <=> $1::vector) AS similarity
FROM chunks WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector LIMIT $2`,
[vectorStr, topK],
);
文档上传 → 解析分片 → 向量化存储 → 查询召回 → 注入 Prompt → 大模型回答
本教程代码分两段:
documents.service.ingest):保存文档 → 分片 → 批量向量化 → 批量写入向量。rag.service.ask):召回(多路)→ 拼接上下文 → 注入 prompt → 大模型生成。// rag/rag.service.ts
async ask(dto: AskDto) {
const chunks = await this.retriever.hybridSearch(dto.question, topK); // 召回
const answer = await this.generateAnswer(dto.question, chunks); // 注入 + 生成
return { answer, sources: ... };
}
# 入库
curl -X POST http://localhost:3002/documents/ingest -H 'Content-Type: application/json' \
-d '{"title":"公司年假政策","content":"根据公司规章制度,员工入职满一年后每年享有 10 天带薪年假……"}'
# RAG 问答
curl -X POST http://localhost:3002/rag/ask -H 'Content-Type: application/json' \
-d '{"question":"员工每年有多少天带薪年假?","topK":3}'
| 策略 | 做法 | 本教程落地 |
|---|---|---|
| 分片优化 | 调 chunkSize / overlap,按语义边界切 | SplitterService 可配参数 |
| 多路召回 | 向量 + 关键词/全文检索合并 | RetrieverService.hybridSearch(向量 <=> + ILIKE) |
| 查询改写 | MultiQuery / 子问题拆解 | 文档讲解(可接 LLM 改写) |
| 重排 Rerank | 召回后用更精细模型重排 | 文档讲解(接 rerank 模型或 LLM 打分) |
| 召回率调优 | 调 ef_search、topK、lists |
SQL 脚本 + 参数 |
多路召回实现(retriever.service.ts):向量结果 + ILIKE 关键词结果合并去重,按相似度降序。
结合 LLM 做数据清洗、结构化提取、自动打标签,提升入库质量:
示例 prompt(清洗):
请清理以下文本中的无关内容(页眉、页码、广告),仅保留正文:{chunk}
这些能力在阶段 4 综合项目中会与知识库入库流程结合。
-- 查询时调 HNSW 搜索范围(越大越准、越慢)
SET hnsw.ef_search = 100;
-- 查看执行计划确认命中索引
EXPLAIN ANALYZE SELECT id FROM chunks ORDER BY embedding <=> $1::vector(1024) LIMIT 3;
注意:数据量小时 PG 会用顺序扫描(Seq Scan)而非索引扫描,这是正常的——顺序扫描在小数据集更快。数据量上来后自动切换 Index Scan。
LIMIT/OFFSET 或游标(大结果集用 keyset 分页)。INSERT ... VALUES (...), (...) 而非逐条 insert。npm run bench
# 批量写入 N 条向量 → 测试相似度检索延迟
完整启动说明、.env 配置、SQL 脚本见 code/03-pg-vector-rag/README.md。
| 现象 | 原因 | 解决 |
|---|---|---|
extension "vector" is not available |
pgvector 未装或装错 PG 版本 | 源码编译 make PG_CONFIG=... install |
column does not have dimensions |
无维度 vector 建 HNSW 索引 | 先 ALTER ... TYPE vector(1024) |
permission denied to create extension |
非超级用户 | 用超级用户执行 CREATE EXTENSION |
| 相似度排序"不准确" | mock 哈希向量非语义向量 | 配置真实 embedding key |
03-pg-vector-rag/
.env
.env.example
README.md
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
scripts/
bench.ts
init-vector.sql
src/
app.module.ts
main.ts
config/
configuration.ts
env.validation.ts
database/
vector.utils.ts
documents/
documents.controller.ts
documents.module.ts
documents.service.ts
splitter.service.ts
embedding/
embedding.module.ts
embedding.service.ts
mock-embedding.ts
entities/
chunk.entity.ts
document.entity.ts
rag/
rag.controller.ts
rag.module.ts
rag.service.ts
retriever.service.ts配套代码:
code/04-full-project/(完整可上线项目) 整合前三阶段全部能力,落地为一个完整的 AI 知识库问答后端。
┌──────────────────────────────┐
│ 客户端(Web / CLI) │
│ REST / SSE / WebSocket │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ NestJS 应用层 │
│ Guard/Pipe/Interceptor/Filter │
├───────────────┬──────────────┤
┌───────────┤ AuthModule ├──────────────┐
│ │ UsersModule │ │
┌──────▼──────┐ ┌───▼────────┐ ┌────▼─────────┐ ┌──▼────────────┐
│ KnowledgeBase│ │ RagModule │ │ LlmModule │ │ ChatModule │
│ 文档/分片/向量 │ │ 召回/生成 │ │ 工厂/路由 │ │ 对话/SSE/WS │
└──────┬──────┘ └───┬────────┘ └────┬─────────┘ └───┬───────────┘
│ │ │ │
┌──────▼────────────▼───────────────▼───────────────▼──────────┐
│ SkillsModule(业务查询 Skill) │
└──────────────────────────────┬───────────────────────────────┘
│
┌─────────────────────────▼──────────────────────────┐
│ 数据层(PostgreSQL 16 + pgvector,HNSW 索引) │
│ users · documents · chunks(vector) · conversations│
│ messages │
└─────────────────────────┬──────────────────────────┘
│
┌─────────────────────────▼──────────────────────────┐
│ 外部大模型(OpenAI 兼容):DeepSeek / 通义 / 字节 │
│ 嵌入模型:通义千问 text-embedding-v3 │
└────────────────────────────────────────────────────┘
请求流转(RAG 问答为例):客户端 → JWT 守卫鉴权 → RagModule 召回(RetrieverService 用 <=> 余弦检索向量)→ 拼接上下文注入 prompt → ModelFactoryService 取模型 → 流式/非流式生成 → 返回。
| 需求功能 | 落地模块 | 说明 |
|---|---|---|
| 注册登录 / JWT / 刷新 | auth + users |
Access + Refresh 双令牌,刷新哈希轮换 |
| 文档上传 / 解析分片 / 向量化 | knowledge-base |
JSON 文本入库 + multer 文件上传,递归切分 + 向量化 |
| RAG 智能问答 + SSE 流式 | rag |
多路召回 + 注入 + 生成,/rag/ask/stream 流式 |
| 多模型智能路由 | llm |
简单→qwen-turbo,复杂→deepseek-reasoner |
| 对话历史 + 多轮上下文 | chat + conversations/messages |
历史持久化,多轮注入 |
| WebSocket 实时对话 | chat(chat.gateway) |
握手 JWT 鉴权 + 逐 token 推送 |
| 工具调用 | skills |
业务查询 Skill(文档数/消息数统计) |
| 接口文档 + 部署 | Swagger + Dockerfile | /api-docs + docker-compose |
| 表 | 关键字段 | 说明 |
|---|---|---|
users |
id, email(unique), password, roles, refreshTokenHash | 用户 |
documents |
id, userId, title, content, status | 知识库文档 |
chunks |
id, documentId, content, embedding vector |
分片 + 向量(HNSW 索引) |
conversations |
id, userId, title | 会话 |
messages |
id, conversationId, role, content | 对话历史 |
# 前置:Node 22+、PostgreSQL 16 + pgvector(见 00-environment-setup.md)
cd code/04-full-project
cp .env.example .env
npm install
npm run start:dev
首次启动自动建表(synchronize)。随后固定向量维度并创建索引:
psql -d tutorial -f scripts/init-vector.sql
访问:服务 http://localhost:3000 ,Swagger http://localhost:3000/api-docs 。
默认
LLM_MOCK=true/EMBEDDING_MOCK=true,无 key 即可跑通全部功能。配置真实 key 后关闭 mock。
cd code/04-full-project
docker compose up -d --build
docker-compose.yml 起两个服务:
postgres:pgvector/pgvector:pg16 镜像(已内置 pgvector)app:多阶段构建的 NestJS 应用生产环境务必修改
.env中的JWT_SECRET与数据库密码,并把synchronize改为false使用迁移。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /auth/register |
注册 |
| POST | /auth/login |
登录 |
| POST | /auth/refresh |
刷新令牌 |
| GET | /auth/profile |
当前用户(需鉴权) |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /knowledge-base/ingest |
文档入库(JSON 文本) |
| POST | /knowledge-base/upload |
上传 txt/md 文件入库 |
| GET | /knowledge-base |
我的文档列表 |
| DELETE | /knowledge-base/:id |
删除文档 |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /rag/ask |
RAG 问答(非流式) |
| POST | /rag/ask/stream |
RAG 问答(SSE 流式) |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /chat |
对话(含工具调用,可选 RAG) |
| POST | /chat/stream |
SSE 流式对话 |
| WS | /chat(socket.io) |
WebSocket 实时对话 |
完整 curl 命令见 code/04-full-project/README.md。
| 现象 | 解决 |
|---|---|
SSE 输出被包成 data: {"data":...} |
用 @SkipTransform 让流式接口跳过统一响应包装 |
| 全局拦截器需 Reflector | 用 APP_INTERCEPTOR 依赖注入注册,而非手动 new |
| 向量 HNSW 索引报无维度 | 先 scripts/init-vector.sql 的 ALTER 固定维度 |
| 注册重复邮箱 500 | 唯一约束冲突,应捕获后返回友好错误(可用全局异常过滤器) |
04-full-project/
.dockerignore
.env
.env.example
Dockerfile
README.md
docker-compose.yml
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
scripts/
init-vector.sql
src/
app.module.ts
main.ts
common/
config/
configuration.ts
env.validation.ts
database/
data-source.ts
vector.utils.ts
entities/
chunk.entity.ts
conversation.entity.ts
document.entity.ts
message.entity.ts
user.entity.ts
modules/面向开发者的系统化学习教程:NestJS 后端 → LangChain 大模型服务 → PostgreSQL 向量检索 + RAG → 综合实战。 全部开源、优先国产大模型(DeepSeek / 通义千问 / 字节 Seed),兼容 OpenAI 协议。 本计划审核确认后,才开始产出教程内容。
| 决策点 | 结论 |
|---|---|
| 代码验证深度 | 编译级(TS 严格类型 + 依赖版本锁定)+ 关键路径跑通(本地 PG、向量写入/检索、RAG 召回) |
| 大模型调用 | 用 Mock LLM 兜底(可离线验证链路),保留真实 key 一键切换 |
| Embedding 模型 | 通义千问 text-embedding-v3(DashScope 兼容端点) |
| 数据库环境 | 本地手动安装 PostgreSQL 16 + pgvector(macOS Homebrew 优先 + Linux 通用步骤) |
| LangChain.js 版本线 | 1.x 最新稳定线(要求 Node 22+) |
| 包管理器 / Node | npm + Node 22 LTS |
| 后端框架 | NestJS 11(Express 引擎,TypeScript 5.7 strict) |
| ORM | TypeORM 0.3.x(@nestjs/typeorm 11) |
阶段1 NestJS 工程化 阶段2 LangChain 大模型服务
┌─────────────────┐ ┌──────────────────────┐
│ 模块/DI/分层 │ → │ 模型工厂/路由/参数/MCP │
│ TypeORM+PG CRUD │ │ Skill/上下文/流式 │
│ JWT/中间件/WS │ │ SSE/WS 流式对话 │
└─────────────────┘ └──────────────────────┘
│ │
└──────────┬────────────────┘
▼
阶段3 PostgreSQL 向量 + RAG(文档分片→向量化→召回→注入)
│
▼
阶段4 综合项目:AI 知识库问答后端(全能力整合 + Docker 部署)
阶段 1 打底(后端骨架 + 鉴权 + 基础设施),阶段 2 补齐 AI 能力(模型管理与流式),阶段 3 补齐知识增强(向量检索 RAG),阶段 4 三合一落地成可上线项目。每阶段独立可运行,前序阶段产出是后续的依赖骨架。
00-environment-setup.md 详解)| 环境 | 版本 | 安装方式(macOS 优先) |
|---|---|---|
| Node.js | 22 LTS(≥22,LangChain 1.x 硬性要求) | nvm / 官网 pkg |
| npm | 随 Node | — |
| PostgreSQL | 16.x | brew install postgresql@16,brew services start postgresql@16 |
| pgvector | 0.8.x | brew install pgvector(源码 make && make install 备选) |
| 验证 pgvector | — | psql 内执行 CREATE EXTENSION IF NOT EXISTS vector; + SELECT extversion FROM pg_extension WHERE extname='vector'; |
sudo apt install postgresql-16 postgresql-16-pgvector。ChatOpenAI({ baseURL, apiKey }) 接入):
https://api.deepseek.com/v1(deepseek-chat / deepseek-reasoner)https://dashscope.aliyuncs.com/compatible-mode/v1(qwen-turbo/qwen-plus/qwen-max)https://ark.cn-beijing.volces.com/api/v3(doubao-* / deepseek-* 端点)text-embedding-v3(同一 DashScope 兼容端点 /embeddings)。01-nestjs-complete-guide.md)| 章 | 主题 | 知识点清单 |
|---|---|---|
| 1.1 | 环境与初始化 | @nestjs/cli 安装、nest new、启动命令(dev/start:prod)、目录结构逐文件讲解(main.ts / app.module / module / controller / service / .env / tsconfig) |
| 1.2 | 核心概念 | 模块化(@Module)、依赖注入(DI 容器/Provider scope)、Controller/Service/Provider 分层、DTO 与 ValidationPipe |
| 1.3 | 配置管理 | @nestjs/config、.env 多环境(.env.development/.test/.production)、Joi/class-validator 配置校验、ConfigModule.forRoot 用法 |
| 1.4 | TypeORM + PG | 连接配置、实体定义(@Entity/@Column/@PrimaryGeneratedColumn)、Repository CRUD、关联(OneToMany/ManyToOne)、事务(DataSource.transaction)、迁移(typeorm migration:generate/run) |
| 1.5 | JWT 鉴权 | bcrypt 密码哈希、注册/登录、@nestjs/jwt 签发、Passport JWT Strategy、@UseGuards 守卫、Access + Refresh Token 双令牌与刷新接口、@Roles 权限控制 |
| 1.6 | 中间件体系 | 中间件/拦截器(Interceptor)/异常过滤器(ExceptionFilter)/管道(Pipe) 四者区别、触发时机与执行顺序、各自实战场景 |
| 1.7 | 文件上传下载 | multer 本地存储、ServeStaticModule 静态资源、文件类型(MIME)与大小限制校验、下载接口 |
| 1.8 | WebSocket | @nestjs/websockets + socket.io、@WebSocketGateway、房间管理、消息广播、WS 握手鉴权(token 校验) |
| 1.9 | 接口文档 | @nestjs/swagger + swagger-ui-express、装饰器注解、自动生成 Swagger UI |
交付:完整基础后端项目(含上述各独立示例 + 整合版)、启动说明、每接口 curl 测试命令。
02-langchain-ai-service.md)按 LangChain.js 1.x 规范编写(.invoke()/.stream()/LCEL .pipe()、ChatPromptTemplate.fromMessages、.bindTools())。
| 章 | 主题 | 知识点清单 |
|---|---|---|
| 2.1 | LangChain 基础 | 1.x 包结构(@langchain/core / @langchain/openai / langchain / @langchain/langgraph / @langchain/classic)、核心概念(Model/Prompt/LCEL/Runnable/Tool)、Nest 模块化封装、模型服务 Provider 初始化 |
| 2.2 | 多模型加载管理 | 模型工厂(Factory)+ 注册表,同时加载 DeepSeek/通义/字节多模型,统一 ChatOpenAI 接口,运行时热切换 |
| 2.3 | 模型参数详解 | temperature/max_tokens/top_p/frequency_penalty/presence_penalty/stop/seed/thinking_mode(推理模型) 全参数作用 + 调优建议 |
| 2.4 | 动态参数配置 | 请求 DTO 动态覆盖模型参数、按场景预设配置(预设 JSON/代码配置)、参数校验 |
| 2.5 | 模型路由策略 | 基于任务类型/复杂度/成本的自动路由(简单问题→轻量 qwen-turbo,复杂→deepseek-reasoner/qwen-max)、路由规则可配置 |
| 2.6 | MCP 协议 | MCP 概念(client/server/tool/resource)、@modelcontextprotocol/sdk + @langchain/mcp-adapters 接入第三方 MCP、开发自定义 MCP Server、通过 HTTP(Streamable HTTP)暴露 MCP 接口 |
| 2.7 | Skill 开发 | Skill 概念、自定义 Skill 封装业务逻辑(StructuredTool)、Skill 注册表与挂载、按场景调用 |
| 2.8 | 本地 API 接入 | 大模型通过工具调用本地 Nest 业务接口/数据库查询(Function Calling),形成工具调用闭环 |
| 2.9 | 对话上下文管理 | 历史消息持久化到 PostgreSQL、上下文窗口管理、Token 计数/压缩/截断、长时记忆方案(摘要/向量记忆) |
| 2.10 | 流式响应 | SSE 流式输出(@Sse + model.stream())、WebSocket 流式对话 |
交付:完整 Nest 集成 LangChain 模块(模型工厂、多模型路由、动态参数 DTO、自定义 MCP、自定义 Skill、上下文管理、流式对话),每个功能附调用示例与测试方法。
03-postgresql-vector-rag.md)| 章 | 主题 | 知识点清单 |
|---|---|---|
| 3.1 | PostgreSQL 基础 | TypeORM 完整 CRUD、复杂查询(QueryBuilder)、事务、索引优化(B-tree/GIN) |
| 3.2 | pgvector 插件 | 安装启用、vector(n) 字段定义、距离算子(<-> L2 / <=> 余弦 / <#> 内积)、HNSW/IVFFlat 索引创建与参数(m/ef_construction/ef_search/lists) |
| 3.3 | 向量数据开发 | 文档分片(递归字符切分/语义切分)、嵌入模型调用(通义 text-embedding-v3)、向量批量写入、相似度检索 |
| 3.4 | RAG 基础实现 | 文档上传 → 解析分片 → 向量化存储 → 查询召回 → 注入 Prompt → 大模型回答(全链路) |
| 3.5 | RAG 优化 | 分片策略优化(重叠/大小)、多路召回(向量+关键词/全文)、查询改写(MultiQuery/子问题)、重排(Rerank)、召回率调优 |
| 3.6 | 大模型数据增强 | 用 LLM 做数据清洗、结构化提取、自动打标签 |
| 3.7 | 性能优化 | 向量索引调优、分页(游标/offset)、批量写入、查询性能压测方法(EXPLAIN ANALYZE、压测脚本) |
交付:TypeORM 向量实体、向量 CRUD 服务、基础 RAG 服务、文档解析分片工具;附 pgvector 安装命令、索引创建 SQL、性能测试脚本。
04-full-project.md)| 功能 | 说明 |
|---|---|
| 用户体系 | 注册登录、JWT 鉴权、Access/Refresh Token |
| 知识库管理 | 文档上传(multer)、自动解析分片(pdf/txt/md)、向量化入库 |
| RAG 智能问答 | 向量召回 + 大模型回答、SSE 流式响应 |
| 多模型路由 | 简单问答→轻量模型,复杂知识库问题→强模型 |
| 对话历史 | 全量持久化、多轮对话上下文(含 Token 压缩) |
| WebSocket 实时对话 | 实时消息、流式输出、房间/会话隔离 |
| 工具调用 | 内置业务查询 Skill,可调本地数据库接口 |
| 文档+部署 | 架构说明、接口清单(Swagger)、Docker 部署(Dockerfile + docker-compose:app + postgres/pgvector) |
tutorial/
├── plan.md # 本计划(作为教程总纲归档)
├── README.md # 教程首页:导航、技术栈、快速开始
├── 00-environment-setup.md # 前置环境:Node/PG/pgvector/API Key 全安装步骤
├── 01-nestjs-complete-guide.md # 阶段1文档
├── 02-langchain-ai-service.md # 阶段2文档
├── 03-postgresql-vector-rag.md # 阶段3文档
├── 04-full-project.md # 阶段4文档(架构+部署+接口清单)
└── code/
├── 01-nest-basic/ # 阶段1完整项目
├── 02-langchain-module/ # 阶段2完整模块
├── 03-pg-vector-rag/ # 阶段3完整项目
└── 04-full-project/ # 阶段4综合项目
code/01-nest-basic/
├── package.json
├── tsconfig.json / tsconfig.build.json
├── nest-cli.json
├── .env.example # 环境变量模板
├── README.md # 启动说明 + curl 测试命令
├── ormconfig.ts / data-source.ts
└── src/
├── main.ts # 全局管道/过滤器/Swagger 装配
├── app.module.ts
├── common/ # 守卫/拦截器/过滤器/装饰器
├── config/ # 多环境配置 + 校验
├── modules/
│ ├── auth/ # 注册/登录/JWT/刷新
│ ├── users/ # CRUD 示例
│ ├── upload/ # 文件上传下载
│ ├── chat/ # WebSocket 网关
│ └── health/
├── entities/ # TypeORM 实体
└── migrations/
阶段 4 综合项目结构(核心模块):
code/04-full-project/src/
├── main.ts
├── app.module.ts
├── common/ # 守卫/拦截器/过滤器/管道/装饰器
├── config/ # 环境配置+校验
├── database/ # TypeORM + pgvector 实体/迁移
├── modules/
│ ├── auth/ # JWT 用户体系
│ ├── users/
│ ├── knowledge-base/ # 文档上传/解析/分片/向量化
│ ├── rag/ # 向量召回 + 注入 + 生成
│ ├── llm/ # 模型工厂/路由/参数/流式
│ ├── chat/ # 对话历史 + SSE + WebSocket 网关
│ ├── skills/ # 业务查询 Skill + 工具注册
│ └── mcp/ # 自定义 MCP Server
└── scripts/ # 压测/种子数据脚本
┌─────────────────────────────┐
│ 客户端 (Web/CLI) │
│ REST / SSE / WebSocket │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ NestJS 应用层 │
│ Guard/Pipe/Interceptor/Filter│
├──────────────┬──────────────┤
┌─────────────┤ AuthModule ├──────────────┐
│ │ UsersModule│ │
┌──────▼─────┐ ┌─────▼─────┐ ┌─────▼─────────┐ ┌───▼──────────┐
│ Knowledge │ │ RAG 服务 │ │ LLM 模块 │ │ Chat(WS/SSE) │
│ Base 模块 │ │ 召回+注入 │ │ 工厂/路由/流式 │ │ 会话/历史 │
└──────┬─────┘ └─────┬─────┘ └─────┬─────────┘ └───┬──────────┘
│ │ │ │
┌──────▼─────────────▼─────────────▼───────────────▼──────────┐
│ Skills / MCP 工具层 │
│ 业务查询 Skill · 自定义 MCP Server · 第三方 MCP 接入 │
└──────────────────────────────┬──────────────────────────────┘
│
┌─────────────────────────▼─────────────────────────┐
│ 数据层(PostgreSQL 16 + pgvector) │
│ users · documents · chunks(向量) · conversations │
│ messages · vector HNSW 索引 │
└───────────────────────────────────────────────────┘
│
┌─────────────────────────▼─────────────────────────┐
│ 外部大模型(OpenAI 兼容协议) │
│ DeepSeek · 通义千问 · 字节Seed · Embedding(v3) │
└───────────────────────────────────────────────────┘
| 表 | 关键字段 | 说明 |
|---|---|---|
users |
id, email, password_hash, roles | 用户 |
documents |
id, user_id, filename, status, meta | 知识库文档 |
chunks |
id, document_id, content, embedding vector(n), token_count |
分片 + 向量(HNSW 索引) |
conversations |
id, user_id, title | 会话 |
messages |
id, conversation_id, role, content, token_count | 对话历史 |
| 需求功能 | 落地模块 |
|---|---|
| 注册登录 / JWT / 刷新 | auth + users |
| 文档上传/解析分片/向量化 | knowledge-base |
| 向量召回 + RAG 回答 + 流式 | rag + llm |
| 多模型智能路由 | llm(模型工厂 + 路由策略) |
| 对话历史 + 多轮上下文 | chat + database |
| WebSocket 实时对话 | chat(WS 网关 + SSE) |
| 业务查询 Skill | skills |
| 自定义 MCP + 本地接口工具闭环 | mcp + skills |
| 依赖 | 版本 | 用途 |
|---|---|---|
| Node.js | 22 LTS | 运行时(LangChain 1.x 硬性要求) |
| typescript | ^5.7 | 严格类型 |
| @nestjs/core / common / platform-express | ^11.0.1 | 框架核心 |
| @nestjs/cli | ^11.0.0 | 脚手架 |
| @nestjs/config | ^4.0.2 | 配置管理 |
| @nestjs/typeorm | ^11.0.0 | ORM 集成 |
| typeorm | ^0.3.22 | ORM |
| pg | ^8.13 | PostgreSQL 驱动(含 pgvector 原生 SQL) |
| class-validator / class-transformer | ^0.14.1 / ^0.5.1 | 校验 |
| reflect-metadata / rxjs | ^0.2.2 / ^7.8.1 | 元数据/响应式 |
| @nestjs/swagger + swagger-ui-express | ^11.0.0 / ^5.0.0 | API 文档 |
| @nestjs/websockets + @nestjs/platform-socket.io + socket.io | ^11.0.0 / ^4.8 | WebSocket |
| @nestjs/jwt + @nestjs/passport + passport + passport-jwt | ^11.0.0 / ^11.0.0 / ^0.7 / ^4.0.1 | JWT 鉴权 |
| bcrypt | ^5.1.1 | 密码哈希 |
| @nestjs/serve-static | ^5.0.0 | 静态资源 |
| multer(@types/multer) | ^1.4.12 | 文件上传(platform-express 内置) |
| joi | ^17.13 | 配置校验 |
| 依赖 | 版本 | 用途 |
|---|---|---|
| @langchain/core | ^1.1.0 | 核心抽象 |
| @langchain/openai | ^1.0.0 | ChatOpenAI / OpenAIEmbeddings(国产模型走 baseURL) |
| langchain | ^1.0.0 | 顶层包(create_agent 等) |
| @langchain/langgraph | ^1.0.0 | Agent 运行时(可选,复杂路由用) |
| @langchain/community | ^1.0.0 | 文档加载器/切分器等(pdf-parse 等) |
| @langchain/mcp-adapters | ^0.6.0 | 接入 MCP 工具 |
| @modelcontextprotocol/sdk | ^1.x | 开发自定义 MCP Server / HTTP 暴露 |
备注:LangChain 1.x 中旧
langchain/chains、部分 retriever 已迁至@langchain/classic,教程阶段 2/3 会明确标注「1.x 写法」与「旧版写法」差异,避免读者踩坑。
| 依赖 | 版本 | 用途 |
|---|---|---|
| pdf-parse | ^1.1.1 | PDF 文本抽取 |
| @langchain/textsplitters(或 RecursiveCharacterTextSplitter) | 随 core | 分片 |
| 依赖 | 版本 | 用途 |
|---|---|---|
| jest / ts-jest / @types/jest | ^29 / ^29 / ^29 | 测试 |
| supertest / @types/supertest | ^7 | E2E |
| @nestjs/testing | ^11.0.1 | 测试工具 |
| ts-node / tsconfig-paths | ^10 / ^4 | 运行 TS |
| eslint + @typescript-eslint + prettier | Nest 默认 | 规范 |
| 阶段 | 建议时长(业余) | 建议时长(全职) | 前置 |
|---|---|---|---|
| 阶段 1 | 1 周 | 3–4 天 | Node/TS 基础 |
| 阶段 2 | 1 周 | 4–5 天 | 阶段 1 |
| 阶段 3 | 5–7 天 | 3–4 天 | 阶段 1(+可选 2) |
| 阶段 4 | 1 周 | 4–5 天 | 全部前序 |
| 合计 | 约 4 周 | 约 2 周 | — |
顺序建议:严格按 1→2→3→4 递进;每阶段「先读文档原理 → 跑通示例代码 → 用 curl 验证 → 再进下一章」。有后端经验者可跳过 1.1–1.3 快速过。阶段 3 的向量部分建议配合 EXPLAIN ANALYZE 实际观察索引命中。
.call()/.run()/langchain/chains 写法已废弃,网上旧资料易误导。教程必须统一 1.x 范式并标注差异;写代码时以官方迁移指南为准逐条核对 API。@langchain/* 各包必须共享同一 @langchain/core 版本(官方明确要求),教程 package.json 用 caret 对齐并锁版本。vector 类型,需用 @Column({ type: 'vector' }) 自定义 + pg 原生 SQL/QueryBuilder 做距离检索,教程要专门讲这个坑。BaseChatModel/ChatOpenAI 兼容接口,保证「无 key 可跑链路、有 key 一键真调」;真实调用需提示流量费用。brew install pgvector 与 postgresql@16 版本不匹配,需给源码编译备选路径。.env,.env.example 不含真实值;Refresh Token 落地要讲清存储与轮换策略。code/ 下对应可运行项目。tsc --noEmit / npm run build 通过、strict: true、禁止 any),阶段 1/3 起本地 PG 跑通关键路径。./tutorial/,最终将本计划归档为 tutorial/plan.md 并生成 tutorial/README.md 导航。tutorial/00-environment-setup.mdcode/01-nest-basic/(编译通过 + PG CRUD/JWT 跑通)code/02-langchain-module/(Mock 链路跑通)code/03-pg-vector-rag/(向量写入/检索/RAG 召回跑通)code/04-full-project/(编译通过 + 关键路径跑通 + Docker 配置)tutorial/plan.md + tutorial/README.md配套代码:../code/01-nest-basic/(完整可运行项目) 技术栈:NestJS 11(Express 引擎)+ TypeScript 5 + TypeORM 0.3 + PostgreSQL 16 建议学习时长:4~5 天(每天 2~3 小时)
本阶段覆盖后端开发所有常用能力。每个知识点按固定结构展开:
🎯 学习目标 → 📖 原理讲解 → 🔨 最小示例 → 🏗️ 完整实现 → ✅ 验证步骤 → ⚠️ 常见坑点 → 📝 课后练习
NestJS 是什么? 一个用 TypeScript 构建 Node.js 服务端的框架。它的核心思想是"架构先行":用**模块(Module)+ 控制器(Controller)+ 服务(Service)**的组织方式,让后端代码像搭积木一样分层组装,特别适合团队协作和中大型项目。
一个 HTTP 请求在 Nest 中的旅程:
客户端请求
│
▼
┌─────────────┐ 根据 URL 路由 ┌──────────────┐ 调用业务方法 ┌─────────────┐
│ Nest 内核 │ ──────────────────► │ Controller │ ────────────────► │ Service │
│ (HTTP 服务器) │ │ (前台接待) │ │ (业务办理) │
└─────────────┘ └──────────────┘ └─────────────┘
# 1. 全局安装 CLI
npm i -g @nestjs/cli
# 2. 创建项目(选择 npm 作为包管理器)
nest new 01-nest-basic
# ? Please choose a package manager: ❯ npm
# 3. 进入项目并启动
cd 01-nest-basic
npm run start
看到 Nest application successfully started 后,打开新终端验证:
curl http://localhost:3000
# 预期输出:Hello World!
启动命令对照表:
| 命令 | 作用 | 使用场景 |
|---|---|---|
npm run start |
编译并启动 | 日常开发 |
npm run start:dev |
监听文件变化自动重启 | 开发首选 |
npm run start:prod |
运行编译后的 dist 产物 | 生产环境 |
npm run build |
只编译不启动 | CI/部署打包 |
01-nest-basic/
├── src/
│ ├── main.ts # 【入口】创建应用、注册全局能力、监听端口
│ ├── app.module.ts # 【根模块】整个应用的"总装配车间"
│ ├── app.controller.ts # 示例控制器:处理 GET /
│ └── app.service.ts # 示例服务:返回 Hello World
├── nest-cli.json # CLI 配置(源码目录、编译选项)
├── tsconfig.json # TypeScript 配置(装饰器支持等)
├── tsconfig.build.json # 编译时排除测试文件的配置
├── package.json # 依赖与脚本
└── node_modules/ # 依赖包
main.ts 逐行解读:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
// 以根模块为蓝图创建应用实例
const app = await NestFactory.create(AppModule);
// 监听 3000 端口,开始接收请求
await app.listen(3000);
}
void bootstrap();
修改 src/app.controller.ts:
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller() // 装饰器:声明这是一个控制器,括号里可填路由前缀
export class AppController {
constructor(private readonly appService: AppService) {}
@Get() // 处理 GET /
getHello(): string {
return this.appService.getHello();
}
@Get('hello') // 处理 GET /hello
hello(): { message: string } {
return { message: '你好,NestJS!' };
}
}
npm run start:dev # 保存文件后自动重启
curl http://localhost:3000/hello
# 预期:{"message":"你好,NestJS!"}
nest: command not found:CLI 没装到全局 PATH。用 npx nest new xxx 代替,或检查 npm 全局 bin 目录是否在 PATH 中。Error: listen EADDRINUSE: address already in use :::3000。用 lsof -i :3000 找到占用进程,或改用其他端口:await app.listen(3101)。Unable to resolve signature:tsconfig 缺少 experimentalDecorators 和 emitDecoratorMetadata,用 CLI 生成的项目不会有此问题,手动建项目时注意。GET /time 接口,返回当前服务器时间的 JSON。PORT 读取(process.env.PORT ?? 3000)。用公司来类比:
| Nest 概念 | 公司类比 | 职责 |
|---|---|---|
| Module | 部门 | 把相关的人(Provider)和业务窗口(Controller)组织在一起,决定谁能被其他部门借用(exports) |
| Controller | 前台 | 只负责接待(接收请求、返回响应),不亲自干活 |
| Service | 业务专员 | 真正干活的人:业务逻辑、数据库操作 |
| Provider | 员工的统称 | 任何可以被"注入"(分配)给别人的类,Service 是最常见的 Provider |
| 依赖注入(DI) | HR 自动分配人手 | 你在构造器里声明"我需要一个 UserService",框架自动创建并递给你,不用自己 new |
为什么需要依赖注入? 对比一下:
// ❌ 手动 new:耦合死,测试时没法替换成假实现
class UsersController {
private usersService = new UsersService(new Repository(...));
}
// ✅ 依赖注入:声明需求即可,框架负责创建与生命周期管理
class UsersController {
constructor(private readonly usersService: UsersService) {}
}
模块的四个配置项:
@Module({
imports: [], // 引入其他模块(借用别人的能力)
controllers: [], // 本模块的控制器
providers: [], // 本模块的服务/提供者
exports: [], // 允许被其他模块使用的 providers
})
nest g module users
nest g controller users
nest g service users
# 简写:nest g mo users / nest g co users / nest g s users
CLI 会自动把新生成的 Controller/Service 注册进 users.module.ts——这就是"模块化"的便利之处。
本项目中的模块组织(见 app.module.ts):
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, ... }), // 全局配置
TypeOrmModule.forRootAsync({ ... }), // 数据库连接
AuthModule, UsersModule, PostsModule, // 业务模块
FilesModule, ChatModule, HealthModule,
],
controllers: [AppController],
providers: [ /* 全局过滤器/拦截器 */ ],
})
export class AppModule {}
自定义 Provider 的四种写法(了解即可,useClass 之外的三种在写测试/动态实现时很有用):
const providers = [
UsersService, // 标准写法(useClass 的简写)
{ provide: 'API_VERSION', useValue: 'v1' }, // useValue:注入一个常量
{ // useFactory:动态创建
provide: 'DB_OPTIONS',
useFactory: (config: ConfigService) => ({ host: config.get('DB_HOST') }),
inject: [ConfigService],
},
{ provide: 'OldService', useExisting: UsersService }, // useExisting:起别名
];
注入常量时用 @Inject:
constructor(@Inject('API_VERSION') private readonly apiVersion: string) {}
cd code/01-nest-basic && npm run start:dev
# 观察启动日志中的路由映射(Mapper):
# [RouterExplorer] Mapped {/api/users, GET} route
# 说明 users 模块的控制器已被根模块装配成功
Nest can't resolve dependencies of XxxService (?):XxxService 依赖的东西没注册。检查:依赖的 Service 是否在同模块 providers 里?跨模块依赖时,对方模块是否 exports 了它?是否 imports 了对方模块?forwardRef(() => BModule) 包裹。更好的做法是重新设计模块边界。TypeOrmModule.forFeature([实体]),见第 4 章。orders 模块(module + controller + service),实现 GET /api/orders 返回一个假订单数组,并在根模块注册。useValue 注入一个常量 APP_NAME,在某个接口中返回它。@nestjs/config 加载 .env 并实现多环境配置为什么不把数据库密码写死在代码里?
解决方案:配置与代码分离——敏感/易变的配置放 .env 文件(加入 .gitignore,不提交),代码通过 process.env 读取。
.env.example(模板,提交到 git) → 开发者复制为 .env(真实值,不提交)
npm i @nestjs/config
echo "PORT=3101" > .env
// app.module.ts
ConfigModule.forRoot({ isGlobal: true }); // isGlobal: 任何模块都能注入 ConfigService
// 任意 Service 中
constructor(private readonly configService: ConfigService) {}
const port = this.configService.get('PORT');
① 多环境配置工厂(configuration.ts):把所有配置集中成一个有类型的对象,还能按 NODE_ENV 区分环境:
export default (): AppConfig => ({
env: process.env.NODE_ENV ?? 'development',
port: parseInt(process.env.PORT ?? '3101', 10),
db: { host: process.env.DB_HOST ?? '127.0.0.1', /* ... */ },
jwt: { accessSecret: process.env.JWT_ACCESS_SECRET ?? '...', /* ... */ },
upload: { dir: process.env.UPLOAD_DIR ?? 'uploads', /* ... */ },
});
② 启动期校验(env.validation.ts):把环境变量当 DTO 校验,缺了关键配置直接拒绝启动(fail fast):
class EnvironmentVariables {
@IsString() DB_PASSWORD!: string;
@IsInt() @Min(1) @Max(65535) PORT!: number;
// ...
}
export function validateEnv(config: Record<string, unknown>) {
const validated = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true, // "5432" 自动转数字
});
const errors = validateSync(validated, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(`环境变量校验失败,请检查 .env:\n${错误详情}`);
}
return validated;
}
③ 装配(app.module.ts):
ConfigModule.forRoot({
isGlobal: true,
load: [configuration], // 加载配置工厂
validate: validateEnv, // 启动期校验
envFilePath: ['.env'],
});
④ 类型安全取值:ConfigService<AppConfig> 泛型 + infer: true,取值有类型提示:
const secret = this.configService.get('jwt.accessSecret', { infer: true });
// 关键配置用 getOrThrow:缺失直接抛错,而不是拿到 undefined 埋雷
const host = this.configService.getOrThrow<string>('db.host', { infer: true });
cd code/01-nest-basic
cp .env.example .env
# 故意删掉 .env 里的 DB_PASSWORD 一行,然后启动:
npm run start:dev
# 预期:启动失败,报错 "环境变量校验失败,请检查 .env:DB_PASSWORD: ..."
# 恢复后重新启动即可正常运行
.env 不生效:envFilePath 是相对进程启动目录的,从项目根目录启动即可;或写绝对路径。parseInt,布尔要自己比较 'true'。.env 提交到了 git:务必在 .gitignore 中写 .env,仓库里只留 .env.example。LOG_LEVEL 字段(默认 info),并在启动日志中打印它。NODE_ENV=production 时关闭 Swagger(提示:在 main.ts 里判断后再 setup)。ORM(对象关系映射):把数据库表映射成 TypeScript 类,把 SQL 变成方法调用。
数据库表 users TS 类 User
┌────┬──────────┐ class User {
│ id │ username │ ←→ id: number;
└────┴──────────┘ username: string;
}
一行记录 = 一个对象实例;查表 = repository.find()
本教程的三个实体关系:
User 1 ──── 1 Profile (一对一:一个用户一份扩展资料)
User 1 ──── N Post (一对多:一个用户发多篇文章)
npm i @nestjs/typeorm typeorm pg
// 实体:一个类 = 一张表
@Entity('users')
export class User {
@PrimaryGeneratedColumn() // 自增主键
id!: number;
@Column({ unique: true, length: 50 }) // varchar(50) 唯一列
username!: string;
}
// app.module.ts 中连接数据库
TypeOrmModule.forRoot({
type: 'postgres',
host: '127.0.0.1', port: 5432,
username: 'tutorial', password: 'tutorial123', database: 'tutorial',
entities: [User],
synchronize: true, // 开发期自动同步表结构(生产禁用!)
});
// 业务模块中注册仓储
TypeOrmModule.forFeature([User])
// Service 中使用
@InjectRepository(User) private readonly userRepo: Repository<User>
await this.userRepo.save(this.userRepo.create({ username: 'alice' }));
① 实体定义(user.entity.ts)——常用列选项:
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'username', type: 'varchar', length: 50, unique: true })
username!: string; // name: 列名;type: 列类型;unique: 唯一索引
@Column({ name: 'role', type: 'varchar', length: 10, default: 'user' })
role!: UserRole; // default: 默认值
@Column({ name: 'refresh_token_hash', type: 'varchar', length: 100, nullable: true })
refreshTokenHash!: string | null; // nullable: 允许 NULL(TS 类型要对应 | null)
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // 自动写入创建时间
@OneToOne('Profile', 'user') // 一对一关联(声明式)
profile?: Profile;
@OneToMany('Post', 'author') // 一对多关联
posts?: Post[];
}
多对一的一方(post.entity.ts)需要外键列 + @JoinColumn:
@Column({ name: 'author_id', type: 'int' })
authorId!: number;
@ManyToOne('User', 'posts')
@JoinColumn({ name: 'author_id' }) // 指定外键列
author?: User;
② Repository 增删改查(users.service.ts):
// 增
const user = this.userRepository.create({ username, passwordHash });
await this.userRepository.save(user);
// 查:按条件查一条
const found = await this.userRepository.findOne({ where: { username } });
// 查:列表 + 模糊匹配 + 限制条数
const list = await this.userRepository.find({
where: { username: ILike(`%${keyword}%`) }, // ILIKE 不区分大小写
take: 20,
});
// 改:先查出来改字段再 save(save 会保留未修改字段)
user.role = 'admin';
await this.userRepository.save(user);
// 删
await this.userRepository.delete(id);
// 原子自增(避免先查后写的并发问题)
await this.postRepository.increment({ id }, 'viewCount', 1);
③ QueryBuilder:复杂查询与分页:
const [list, total] = await this.userRepository
.createQueryBuilder('u') // u 是表别名
.leftJoinAndSelect('u.profile', 'profile') // 关联加载扩展资料
.where('u.username ILIKE :kw', { kw: `%${keyword}%` }) // 参数化防注入
.orderBy('u.id', 'ASC')
.skip((page - 1) * pageSize) // OFFSET
.take(pageSize) // LIMIT
.getManyAndCount(); // 同时拿列表和总数
④ 关联查询的两种写法:
// 写法 A:relations 声明式(简单场景推荐)
this.userRepository.findOne({ where: { id }, relations: { profile: true } });
// 写法 B:QueryBuilder leftJoinAndSelect(需要 where/分页时推荐)
⑤ 事务(posts.service.ts)——发帖必须"插入文章 + 作者计数 +1"同生共死:
return this.dataSource.transaction(async (manager) => {
// ⚠️ 事务内必须用回调参数 manager,不能用 this.xxxRepository!
const author = await manager.findOne(User, {
where: { id: dto.authorId },
lock: { mode: 'pessimistic_write' }, // 悲观锁:防止并发问题
});
if (!author) throw new NotFoundException('作者不存在,事务将回滚');
const saved = await manager.save(manager.create(Post, { ...dto }));
await manager.increment(User, { id: dto.authorId }, 'postCount', 1);
return saved;
// 回调正常结束 → 自动 commit;抛异常 → 自动 rollback
});
⑥ 迁移脚本——生产环境表结构的版本管理:
# data-source.ts 是迁移 CLI 专用的 DataSource(synchronize 必须 false)
npm run migration:run # 执行未运行的迁移(建表)
npm run migration:revert # 回滚最近一次
npm run migration:generate -- src/database/migrations/AddXxx # 对比实体自动生成迁移
迁移脚本长这样(1700000000000-InitSchema.ts):
export class InitSchema1700000000000 implements MigrationInterface {
async up(qr: QueryRunner) { /* 升级 SQL:CREATE TABLE ... */ }
async down(qr: QueryRunner) { /* 回滚 SQL:DROP TABLE ... */ }
}
cd code/01-nest-basic && cp .env.example .env && npm run start:dev
# synchronize 会自动建表。登录数据库确认:
docker exec -it tutorial-pg psql -U tutorial -d tutorial -c "\dt"
# 预期看到 users / profiles / posts / migrations 四张表
# 接口验证(需先完成第 5 章注册登录拿到 TOKEN):
curl -X POST http://localhost:3101/api/posts \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"测试事务","content":"内容","authorId":1}'
# 然后查 users 表确认 post_count 变成 1:
docker exec -it tutorial-pg psql -U tutorial -d tutorial \
-c "SELECT username, post_count FROM users;"
# 事务回滚验证:authorId 传不存在的 999 → 返回 404,且 posts 表无新增
No metadata for "User" was found:实体没注册。检查 forFeature([User]) 和 forRoot 的 entities 数组。manager。synchronize: false + 迁移脚本。findOne 不带 where 报错:TypeORM 0.3 要求 findOne({ where: {...} }),多条匹配会抛错,取最新一条用 find({ order: {id:'DESC'}, take: 1 })。string | null,否则严格模式下编译/运行都会别扭。tags 字段(varchar),并实现按 tag 搜索文章的接口。balance 列,实现 A 给 B 转账,余额不足时整体回滚。Session vs JWT:
| Session(门禁卡模式) | JWT(身份证模式) | |
|---|---|---|
| 凭证存哪 | 服务器内存/Redis 存会话 | 客户端自己保存(一段签名的字符串) |
| 服务器压力 | 要存所有会话,扩容麻烦 | 无状态,只验签名,天然适合分布式 |
| 吊销 | 删会话即可 | 需要额外机制(本教程用 refresh token 哈希存库) |
JWT 结构:header.payload.signature 三段 Base64。payload 放用户信息(不要放密码等敏感数据,它只是编码不是加密!),signature 用服务器密钥签名——篡改 payload 会导致验签失败。
双 Token 机制:
登录成功
├─ accessToken(15 分钟):每次请求携带,短期有效降低泄露风险
└─ refreshToken(7 天):只用于换新 token,存库哈希,支持轮换
accessToken 过期 → 用 refreshToken 调 /auth/refresh → 拿到新的一对(旧的作废)
完整鉴权链路:
请求带 Authorization: Bearer <accessToken>
▼
JwtAuthGuard(守卫:决定能不能进)
▼ 调用 JwtStrategy
从 Header 提取 token → 用密钥验签 → 解析 payload → 挂到 request.user
▼
RolesGuard(可选:检查角色)
▼
Controller 处理(用 @CurrentUser() 取登录用户)
npm i @nestjs/jwt @nestjs/passport passport passport-jwt bcryptjs
npm i -D @types/passport-jwt @types/bcryptjs
// 签发
const token = await this.jwtService.signAsync({ sub: user.id, username: user.username }, {
secret: 'my-secret', expiresIn: '15m',
});
// 验证
const payload = await this.jwtService.verifyAsync(token, { secret: 'my-secret' });
① 注册:密码绝不存明文(auth.service.ts):
// bcrypt 加盐哈希:同样的密码每次哈希结果都不同(盐随机),无法反推原文
const passwordHash = await bcrypt.hash(password, 10); // cost=10
await this.userRepository.save(this.userRepository.create({ username, passwordHash }));
// 登录时比对
const matched = await bcrypt.compare(password, user.passwordHash);
② JWT 策略(jwt.strategy.ts):
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService<AppConfig>) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // 从 Bearer 头提取
ignoreExpiration: false, // 过期即拒绝
secretOrKey: configService.get('jwt.accessSecret', { infer: true }) as string,
});
}
// 返回值会挂到 request.user
validate(payload: JwtPayload): JwtPayload {
if (payload.type !== 'access') throw new Error('token 类型非法');
return payload;
}
}
③ 守卫与装饰器:
// jwt-auth.guard.ts:一行继承,自动接入 JwtStrategy
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// roles.decorator.ts:打标记
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts:读标记并比对(需配合 Reflector)
const required = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(), context.getClass(),
]);
// current-user.decorator.ts:从 request.user 取登录信息
export const CurrentUser = createParamDecorator((_d, ctx) =>
ctx.switchToHttp().getRequest().user);
④ 使用(auth.controller.ts / users.controller.ts):
@Get('profile')
@UseGuards(JwtAuthGuard) // 需要登录
profile(@CurrentUser() user: JwtPayload) { return user; }
@Patch(':id/role')
@UseGuards(JwtAuthGuard, RolesGuard) // 登录 + 角色检查
@Roles('admin') // 仅管理员
updateRole(...) {}
⑤ 刷新与轮换:refresh token 的 sha256 哈希存库,每次刷新后旧 token 立即失效(防重放)。
# 完整链路见 code/01-nest-basic/README.md,核心命令:
curl -X POST http://localhost:3101/api/auth/register \
-H 'Content-Type: application/json' -d '{"username":"alice","password":"Passw0rd123"}'
curl -X POST http://localhost:3101/api/auth/login \
-H 'Content-Type: application/json' -d '{"username":"alice","password":"Passw0rd123"}'
# 拿到 accessToken / refreshToken
export TOKEN="<accessToken>"
curl http://localhost:3101/api/auth/profile -H "Authorization: Bearer $TOKEN" # 200
curl http://localhost:3101/api/auth/profile # 401
# 用同一个 refreshToken 连续刷新两次 → 第二次 401(轮换生效)
Unknown authentication strategy "jwt":JwtStrategy 没注册到模块 providers。@UseGuards(JwtAuthGuard, RolesGuard)。一次请求的完整生命周期(务必记住这张图):
客户端请求
│
▼
[Middleware 中间件] 最外层:日志、CORS、预处理(Express 风格,next() 传递)
│
▼
[Guard 守卫] 决定"能不能进":鉴权、角色(第 5 章已学)
│
▼
[Interceptor 拦截器·前] 进入处理器之前:记录开始时间等
│
▼
[Pipe 管道] 参数转换与校验:字符串转数字、DTO 校验
│
▼
[Route Handler] 你的 Controller 方法
│
▼
[Interceptor 拦截器·后] 处理结果返回前:包装响应结构、记录耗时
│
▼
客户端响应
✗ 任何环节抛异常 → [Exception Filter 异常过滤器] 统一兜底
一句话选型:
| 需求 | 用什么 |
|---|---|
| 记录日志 / 跨域 / 修改请求头 | 中间件 |
| 判断请求有没有资格执行 | 守卫 |
| 包装响应结构 / 缓存 / 超时 | 拦截器 |
| 校验和转换入参 | 管道 |
| 统一错误响应格式 | 异常过滤器 |
① 中间件:请求日志(request-logger.middleware.ts):
@Injectable()
export class RequestLoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} - ${Date.now() - start}ms`);
});
next(); // 必须调用 next(),否则请求卡死
}
}
// app.module.ts 中注册(模块级)
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestLoggerMiddleware).forRoutes('*');
}
}
② 拦截器:统一响应结构(transform.interceptor.ts):
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({ code: 0, message: 'success', data })), // 所有返回值包上外壳
);
}
}
③ 异常过滤器:统一错误响应(all-exceptions.filter.ts):
@Catch() // 捕获所有异常
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const status = exception instanceof HttpException
? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
// ... 提取 message(校验错误是数组,要 join)
response.status(status).json({ code: status, message, data: null });
}
}
④ 管道:全局校验 + 自定义转换:
// main.ts:全局 ValidationPipe
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // 剔除 DTO 未声明的字段
forbidNonWhitelisted: true, // 出现未声明字段直接 400
transform: true, // 自动类型转换(配合 DTO 的 @Type)
}));
// parse-id.pipe.ts:把 :id 转成正整数,否则 400
@Injectable()
export class ParseIdPipe implements PipeTransform<string, number> {
transform(value: string): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
throw new BadRequestException(`非法的 id 参数:${value}`);
}
return id;
}
}
// 使用:findOne(@Param('id', ParseIdPipe) id: number)
⑤ 全局注册(app.module.ts providers):
providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
],
# 统一响应结构:任何成功接口都被包成 { code, message, data }
curl http://localhost:3101/api/health
# {"code":0,"message":"success","data":{"status":"ok",...}}
# 管道校验:非法 id → 400
curl http://localhost:3101/api/users/abc -H "Authorization: Bearer $TOKEN"
# DTO 校验:注册时密码不合规 → 400 且 message 列出所有错误
curl -X POST http://localhost:3101/api/auth/register \
-H 'Content-Type: application/json' -d '{"username":"ab","password":"123"}'
# 异常过滤器:访问不存在的路由 → 统一 JSON 错误格式(而非 HTML)
curl http://localhost:3101/api/no-such-route
# 服务端控制台可看到中间件打印的请求日志
whitelist: true,或 DTO 属性没加任何 class-validator 装饰器(没装饰器的字段不算"已声明")。@Type(() => Number) + transform: true。X-Response-Time 头。BizException(code, message) 和对应过滤器,让业务错误返回 code: 10001 这类自定义码。HTTP 上传文件用 multipart/form-data 格式(把文件切成块编码进请求体)。Nest 底层用 multer 处理解析,FileInterceptor('file') 中的 'file' 就是表单字段名。
两种存储策略:
memoryStorage():文件先到内存,由我们校验后再落盘(本教程采用,可以先拦截非法文件);diskStorage():直接写磁盘,性能好但校验滞后。① 上传接口(files.controller.ts):
@Post('upload')
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB 硬限制,超出直接 413/400
}))
upload(@UploadedFile() file: Express.Multer.File) {
return this.filesService.save(file);
}
② 校验与落盘(files.service.ts):
const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.txt', '.md']);
async save(file: Express.Multer.File): Promise<UploadedFileInfo> {
// 1. 扩展名白名单
const ext = extname(file.originalname).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) throw new BadRequestException(`不支持的文件类型:${ext}`);
// 2. 大小双保险
if (file.size > this.maxSize) throw new BadRequestException('文件超过大小限制');
// 3. 按日期分目录 + 随机文件名(防重名、防路径穿越)
const dateDir = new Date().toISOString().slice(0, 7); // 2026-08
const filename = `${randomUUID()}${ext}`;
await mkdir(join(this.uploadDir, dateDir), { recursive: true });
await writeFile(join(this.uploadDir, dateDir, filename), file.buffer);
return { filename: `${dateDir}/${filename}`, url: `/api/files/static/${dateDir}/${filename}`, ... };
}
③ 静态资源服务(files.module.ts):
ServeStaticModule.forRootAsync({
useFactory: (configService: ConfigService<AppConfig>) => [{
rootPath: join(process.cwd(), configService.get('upload.dir', { infer: true }) ?? 'uploads'),
serveRoot: '/api/files/static', // 访问前缀
}],
inject: [ConfigService],
});
// 浏览器打开 /api/files/static/2026-08/xxx.png 即可看到图片
④ 流式下载:
@Get('download/*path')
download(@Param('path') path: string): Promise<StreamableFile> {
return this.filesService.download(p);
}
// service 中:
const fullPath = resolve(this.uploadDir, relativePath);
if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('非法路径'); // 防路径穿越
const stream = createReadStream(fullPath); // 流式读取,大文件不占内存
return new StreamableFile(stream, { disposition: `attachment; filename="..."` });
echo "hello nestjs" > /tmp/test.txt
curl -X POST http://localhost:3101/api/files/upload \
-H "Authorization: Bearer $TOKEN" -F "file=@/tmp/test.txt"
# 返回 { filename: "2026-08/xxx.txt", url: "/api/files/static/2026-08/xxx.txt", ... }
curl http://localhost:3101/api/files/static/2026-08/xxx.txt # 静态访问
curl -OJ http://localhost:3101/api/files/download/2026-08/xxx.txt \
-H "Authorization: Bearer $TOKEN" # 下载
# 非法类型 → 400
echo x > /tmp/a.exe && curl -X POST http://localhost:3101/api/files/upload \
-H "Authorization: Bearer $TOKEN" -F "file=@/tmp/a.exe"
@UploadedFile() 拿到 undefined:表单字段名和 FileInterceptor('xxx') 的参数不一致;或请求没带 multipart/form-data(curl 用 -F 会自动带)。client_max_body_size 限制。resolve 后的路径仍在上传目录内(代码中已演示)。FilesInterceptor('files', 5),最多 5 个)。file_records 表,记录谁在什么时候上传了什么文件。@WebSocketGateway 实现消息收发HTTP vs WebSocket:HTTP 像发短信——每次都是客户端主动问,服务器被动答;WebSocket 像打电话——一次握手后建立长连接,双方随时说话。聊天室、实时通知、协同编辑这类场景必须用 WebSocket。
HTTP: 客户端 ──请求──► 服务器 (每次都要重新建立连接)
客户端 ◄──响应── 服务器
WebSocket:客户端 ◄════════► 服务器 (一次握手,双向实时)
socket.io 的两个关键概念:
/chat;① 网关声明与握手鉴权:
@WebSocketGateway({ namespace: '/chat', cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server!: Server; // 注入 server 实例,用于广播
handleConnection(client: Socket): void {
// 客户端连接时从 handshake.auth 取 token 校验
const token = client.handshake.auth?.token as string | undefined;
try {
const payload = this.jwtService.verify(token, { secret: accessSecret });
client.data.user = { sub: payload.sub, username: payload.username };
} catch {
client.disconnect(true); // 鉴权失败直接断开
}
}
}
② 事件处理与房间:
@SubscribeMessage('join_room') // 监听客户端 emit 的事件名
joinRoom(@ConnectedSocket() client: Socket, @MessageBody() room: string) {
void client.join(room);
client.to(room).emit('system', { message: `${username} 加入了房间` }); // 通知房内其他人
}
@SubscribeMessage('send_message')
sendMessage(@ConnectedSocket() client: Socket, @MessageBody() payload: ChatMessage) {
// 广播给房间内所有人(含自己);to() 指定房间
this.server.to(payload.room).emit('new_message', {
from: (client.data.user as SocketUser).username,
content: payload.content,
at: new Date().toISOString(),
});
}
③ 客户端连接:
const { io } = require('socket.io-client');
const socket = io('http://localhost:3101/chat', {
auth: { token: '<accessToken>' }, // 握手鉴权
});
socket.on('connected', console.log);
socket.emit('join_room', 'room1');
socket.emit('send_message', { room: 'room1', content: '你好' });
socket.on('new_message', console.log);
完整脚本见 code/01-nest-basic/README.md 的 ws-test.js:
cd code/01-nest-basic && npm i socket.io-client
export TOKEN="<你的 accessToken>"
node ws-test.js
# 预期输出:已连接 → 已加入房间 room1 → 收到消息(自己发的也会广播回来)
# 不带 token 连接 → 立即被断开
client.disconnect() 优雅拒绝。cors: { origin: ... }。get_online_count 事件返回人数。send_private 事件只发给指定 username 的用户(提示:连接时把 client.id 按 username 存进 Map)。@nestjs/swagger 自动生成可交互的 API 文档手写接口文档又累又容易过时。Swagger 的思路是:文档从代码装饰器自动生成——代码即文档,永远同步。
① 初始化(main.ts):
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
const swaggerConfig = new DocumentBuilder()
.setTitle('01-nest-basic 教程接口文档')
.setDescription('阶段 1:NestJS 后端工程化全流程示例接口')
.setVersion('1.0')
.addBearerAuth() // 开启 Bearer Token 认证支持
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document); // 挂载到 /api/docs
② 装饰器描述接口(各 controller 已示范):
@ApiTags('认证') // 分组标签
@Controller('auth')
export class AuthController {
@Post('login')
@ApiOperation({ summary: '登录,返回双 token' }) // 接口说明
login(@Body() dto: LoginDto) { ... }
}
// DTO 字段描述
export class RegisterDto {
@ApiProperty({ description: '用户名', example: 'alice' })
@MinLength(3)
username!: string;
}
// 受保护接口标记(文档页会出现锁形图标)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
POST /api/auth/login 拿到 accessToken;@ApiOperation 与 @ApiResponse({ status: 401, description: '未登录' })。@ApiQuery 给 GET /api/users 的分页参数补充文档说明。学完本阶段后,请逐条自检(都能回答再进入阶段 2):
Nest can't resolve dependencies 报错如何排查?synchronize: true?迁移脚本怎么生成和运行?whitelist 与 forbidNonWhitelisted 的区别?查询参数为什么需要 @Type(() => Number)?manager 而不是 this.repository?下一阶段:阶段 2:LangChain 大模型服务开发 —— 在 NestJS 中模块化集成 LangChain,实现多模型管理、智能路由、MCP、Skill 与流式对话。
01-nest-basic/
.env.example
.gitignore
README.md
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
src/
app.controller.ts
app.module.ts
main.ts
auth/
auth.controller.ts
auth.module.ts
auth.service.ts
chat/
chat.gateway.ts
chat.module.ts
common/
config/
configuration.ts
env.validation.ts
database/
data-source.ts
entities/
post.entity.ts
profile.entity.ts
user.entity.ts
files/
files.controller.ts
files.module.ts
files.service.ts
health/
health.controller.ts
health.module.ts
posts/
posts.controller.ts
posts.module.ts
posts.service.ts
users/
users.controller.ts
users.module.ts
users.service.ts配套代码:../code/02-langchain-module/(完整可运行项目) 技术栈:NestJS 11 + LangChain.js 1.x(
@langchain/core1.2 /@langchain/langgraph1.4)+ PostgreSQL 16 建议学习时长:4~5 天(每天 2~3 小时)
⚠️ 版本提示:LangChain JS 在 1.x 有大量破坏性变更,网上 2024 年前的教程 import 路径大多已失效。本教程全部按 1.x 编写,import 一律以
@langchain/core为准。
LangChain 是什么? 类比:大模型本身像一个只会"聊天"的天才大脑;LangChain 是给它配的万能遥控器 + 工具箱——统一的模型调用接口、提示词模板、工具调用、记忆管理、Agent 编排,都用标准化的积木提供。
LangChain.js 1.x 包结构:
| 包 | 职责 |
|---|---|
@langchain/core |
核心抽象:消息类型、ChatModel 基类、tool()、提示词模板 |
@langchain/openai |
OpenAI 及兼容协议集成(国产模型都走它) |
@langchain/langgraph |
Agent 与状态图编排(createReactAgent) |
@langchain/mcp-adapters |
MCP 协议工具适配 |
核心概念速览:
ChatModel 对话模型实例,invoke(消息数组) → AI 回复
Messages SystemMessage(设定) / HumanMessage(用户) / AIMessage(助手)
Tool 模型可以调用的函数(zod 描述参数)
Agent "思考→调工具→观察→再思考"的循环执行体(ReAct)
国产模型怎么接? DeepSeek、通义千问、字节 Seed 都提供 OpenAI 兼容协议端点。所以只需 ChatOpenAI + 改 baseURL + 厂商 Key,一套代码通吃所有模型。
npm i @langchain/core @langchain/openai
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
model: 'deepseek-chat',
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: { baseURL: 'https://api.deepseek.com' }, // 关键:指向国产模型端点
});
const res = await model.invoke('用一句话介绍你自己');
console.log(res.content);
把模型能力做成 LlmModule,业务模块注入即用(llm.module.ts):
@Module({
providers: [ModelFactoryService, ModelRouterService, ParamsService],
exports: [ModelFactoryService, ModelRouterService, ParamsService],
})
export class LlmModule {}
Node 环境注意点(main.ts):
// Node 18 没有全局 Web Crypto,LangChain 依赖它,需手动补齐
import { webcrypto } from 'crypto';
if (!globalThis.crypto) {
(globalThis as unknown as { crypto: Crypto }).crypto = webcrypto as unknown as Crypto;
}
cd code/02-langchain-module && cp .env.example .env # 填入一个 API Key
npm install && npm run start:dev
curl http://localhost:3102/api/llm/models # 看到你配置的模型 configured: true
Cannot find module '@langchain/xxx':1.x 拆分了包,tool 在 @langchain/core/tools,消息在 @langchain/core/messages,别用旧教程的 langchain/... 路径。globalThis.crypto。LlmModule 中新增一个 EchoService,注入到某个 Controller 返回固定文本,体会模块导出/注入。同时接多个模型要解决三个问题:
① 模型注册表(model-registry.ts)——新增模型只需加一条元数据:
export interface ModelMeta {
name: string; // 逻辑名(路由/接口用)
provider: ModelProvider; // deepseek / qwen / seed / openai
modelName: string; // 发给 API 的真实模型 id
tier: 'light' | 'strong'; // 成本/能力档位
contextLength: number; // 上下文窗口
supportsTools: boolean; // 是否支持工具调用
supportsThinking: boolean; // 是否支持思考模式
apiKeyEnv: string; // Key 环境变量名
baseUrlEnv: string; // Base URL 环境变量名
defaultBaseUrl: string;
}
export const MODELS: ModelMeta[] = [
{ name: 'deepseek-chat', provider: 'deepseek', modelName: 'deepseek-chat',
tier: 'light', supportsTools: true, apiKeyEnv: 'DEEPSEEK_API_KEY', ... },
{ name: 'deepseek-reasoner', tier: 'strong', supportsThinking: true, ... },
{ name: 'qwen-turbo', provider: 'qwen', tier: 'light', ... },
{ name: 'qwen-plus', provider: 'qwen', tier: 'strong', ... },
// ...
];
② 模型工厂(model-factory.service.ts):
getModel(name: string, overrides?): ChatOpenAI {
const meta = findModelMeta(name);
if (!meta) throw new NotFoundException(`未知模型:${name}`);
const apiKey = process.env[meta.apiKeyEnv];
if (!apiKey || apiKey === 'sk-please-fill') {
throw new NotFoundException(`模型 ${name} 未配置 API Key:请设置 ${meta.apiKeyEnv}`);
}
if (overrides) return this.createInstance(meta, apiKey, overrides); // 带参数不走缓存
const cached = this.instances.get(name);
if (cached) return cached; // 命中缓存
const instance = this.createInstance(meta, apiKey);
this.instances.set(name, instance); // 写入缓存
return instance;
}
热切换的本质:切换模型 = 换一个逻辑名去工厂取实例,实例池不变,无需重启。
curl http://localhost:3102/api/llm/models
# 返回每个模型的 configured 状态;只配了 DeepSeek 时,qwen/seed 为 false
.env 不提交 git。modelName 要填你在方舟控制台创建的接入点 ID(形如 ep-xxxx),教程注册表中是示例名,实际使用请替换。qwen-max 模型(strong 档)。releaseModel(name) 方法并写接口调用它,观察下次调用重新创建实例的日志。模型生成下一个 token 时,会给所有候选词打分再采样,参数就是调节"怎么选"的旋钮:
| 参数 | 作用 | 调优建议 |
|---|---|---|
temperature |
随机性总开关。0=每次选最高分(严谨如考试),越高越天马行空 | 代码/事实问答 0~0.3;闲聊 0.7~0.9;创意写作 1+ |
topP |
核采样:只从累计概率前 P 的候选里选 | 与 temperature 二选一调节,一般 0.8~1 |
maxTokens |
最多生成多少 token | 按场景设上限,防止跑飞烧钱 |
frequencyPenalty |
惩罚重复词(按出现次数) | 减少车轱辘话:0.2~0.5 |
presencePenalty |
惩罚已出现过的词(不论次数) | 鼓励话题发散:0.2~0.5 |
stop |
遇到指定字符串停止生成 | 结构化输出截断 |
seed |
固定随机种子(部分厂商支持) | 结果复现 |
streaming |
是否流式返回 | 对话 UI 必开 |
timeout / maxRetries |
超时与重试 | 生产建议 30~60s、重试 2 次 |
思考模式(thinking mode):DeepSeek deepseek-reasoner 等推理模型会先输出长段推理过程(reasoning_content)再给答案。注意:
# 同一问题,不同 temperature,观察输出差异
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' \
-d '{"message":"给咖啡店起个名字","temperature":0}'
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' \
-d '{"message":"给咖啡店起个名字","temperature":1.5}'
ParamsService 已按模型能力自动裁剪(见第 4 章)。seed 也并非所有厂商支持。deepseek-reasoner 支持哪些参数,更新注册表元数据。① 请求 DTO(chat.dto.ts):
export class ChatDto {
@IsString() @MinLength(1) @MaxLength(8000) message!: string;
@IsOptional() @IsString() model?: string; // 不传则由路由决定
@IsOptional() @IsEnum(ChatScene) scene?: ChatScene; // chat/translate/code/rag
@IsOptional() @IsNumber() @Min(0) @Max(2) temperature?: number;
@IsOptional() @IsNumber() @Min(0.01) @Max(1) topP?: number;
@IsOptional() @IsInt() @Min(1) @Max(8192) maxTokens?: number;
}
② 场景预设 + 合并(params.service.ts):
const SCENE_PRESETS: Record<ChatScene, ResolvedParams> = {
chat: { temperature: 0.8, maxTokens: 1024 }, // 闲聊自然些
translate: { temperature: 0.2, maxTokens: 2048 }, // 翻译忠实原文
code: { temperature: 0.1, maxTokens: 4096 }, // 代码严谨
rag: { temperature: 0.3, maxTokens: 2048 }, // 基于资料回答
};
resolve(dto: ChatDto): ResolvedParams {
const base = dto.scene ? SCENE_PRESETS[dto.scene] : MODEL_DEFAULTS;
const resolved = {
temperature: dto.temperature ?? base.temperature, // 请求优先
maxTokens: dto.maxTokens ?? base.maxTokens,
topP: dto.topP ?? base.topP,
};
// 按模型能力裁剪:推理模型不接受 temperature/topP
if (dto.model && findModelMeta(dto.model)?.supportsThinking) {
resolved.temperature = 1;
resolved.topP = undefined;
}
return resolved;
}
curl http://localhost:3102/api/llm/presets # 查看预设表
# 越界参数 → 400 校验错误
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' -d '{"message":"hi","temperature":9}'
transform: true 后 JSON body 的数字本来就是数字;但查询参数是字符串,注意区分。summarize 场景预设(低温度、大 maxTokens)。frequencyPenalty 字段并打通到模型调用。类比:简单问题问实习生(快、便宜),复杂问题问专家(贵但靠谱)。路由器的输入是用户消息,输出是"用哪个模型 + 理由"。
本教程的启发式评分(0~100):
基础分 20
+ 长度:超过 100 字后每 50 字 +5(上限 +30)
+ 关键词:命中"分析/证明/方案/对比/架构…"每词 +10(上限 +30)
+ 代码特征:含代码块或多行 +20
score ≥ 60 → 强模型,否则轻量模型
route(message: string, preferredModel?: string): RouteDecision {
if (preferredModel) return { model: preferredModel, reason: '用户显式指定', score: 0 };
const score = this.scoreComplexity(message);
const target = score >= 60 ? strongName : lightName;
if (this.modelFactory.isAvailable(target)) {
return { model: target, reason: `复杂度 ${score}…`, score };
}
// fallback:目标没配 Key → 找同档位可用模型,再不行任意可用模型
const fallback = this.findFallback(target);
if (fallback) return { model: fallback, reason: `${target} 未配置,降级`, score };
throw new Error('没有任何可用的大模型…');
}
进阶:还可以用一次廉价的 LLM 分类调用判断任务类型("这是闲聊还是复杂分析?"),比关键词规则更准,但多一次网络开销。生产系统常用"规则优先 + LLM 兜底"的混合策略。
curl -X POST http://localhost:3102/api/chat/route-demo \
-H 'Content-Type: application/json' -d '{"message":"你好"}'
# → 轻量模型,complexity ≈ 20
curl -X POST http://localhost:3102/api/chat/route-demo \
-H 'Content-Type: application/json' \
-d '{"message":"请详细分析并设计一个高并发秒杀系统的架构方案"}'
# → 强模型,complexity ≥ 60
ROUTER_LIGHT_MODEL / ROUTER_STRONG_MODEL)。budget: 'low'|'high',low 时强制轻量模型。MultiServerMCPClient 接入第三方 MCP 工具MCP(Model Context Protocol) 类比:大模型工具的 USB 接口标准。以前每个 AI 应用接每个工具都要写一套专用对接代码;有了 MCP,工具方实现一次 MCP Server,任何 MCP 客户端(Agent)即插即用。
MCP Server(工具提供方) MCP Client(Agent 侧)
┌──────────────────────┐ ┌─────────────────────┐
│ tools: 可调用的函数 │ ◄────────► │ MultiServerMCPClient │
│ resources: 可读取的数据│ 协议通信 │ → 转成 LangChain 工具│
│ prompts: 提示词模板 │ │ → 注入 Agent │
└──────────────────────┘ └─────────────────────┘
三种 transport:
| transport | 场景 |
|---|---|
| stdio | 本地子进程(npx @modelcontextprotocol/server-filesystem) |
| Streamable HTTP | 远程服务,一个端点双向通信(推荐) |
| SSE | 旧版远程方案,逐步被 Streamable HTTP 取代 |
① 接入第三方 MCP(mcp-client.service.ts):
// mcp.example.json:声明要连哪些 MCP server
{ "mcpServers": {
"filesystem": { "transport": "stdio",
"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }
} }
// 代码:一行接入,工具自动转成 LangChain 工具
this.client = new MultiServerMCPClient(config);
const tools = await this.client.getTools(); // 注入 Agent 即可用
② 开发自定义 MCP Server(mcp-server/tools.ts)——MCP 协议本质就是响应两类请求:
const server = new Server({ name: 'tutorial-mcp-server', version: '1.0.0' },
{ capabilities: { tools: {} } });
// tools/list:我有哪些工具
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: 'safe_calculate', description: '…', inputSchema: {…} }],
}));
// tools/call:执行指定工具(参数必须用 zod 校验,MCP 入参不可信)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'safe_calculate') { /* 校验 + 执行 */ }
});
③ 两种暴露方式:
npm run mcp-server,第三方客户端按 command/args 接入;/api/mcp,用 Mcp-Session-Id 头管理会话。# 1. MCP Inspector 图形化调试(推荐)
npx @modelcontextprotocol/inspector
# Transport 选 Streamable HTTP,URL 填 http://localhost:3102/api/mcp
# 在 Tools 页可看到 get_server_time / safe_calculate 并在线调用
# 2. 让大模型使用 MCP 工具:问它"现在服务器几点了"
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' -d '{"message":"现在服务器时间是几点?"}'
# 观察返回的 toolCalls 中包含 get_server_time
console.error(stderr)。Mcp-Session-Id 头。random_joke 工具。@modelcontextprotocol/server-fetch,让模型能抓取网页内容。Tool 是一个函数,Skill 是一套带说明书的工作流。
calculate(expr) —— 一次调用一个明确动作;Skill 用 Markdown 文件(SKILL.md)承载,好处:非程序员也能编写维护、随时增删不用改代码。
渐进式披露(省 token 的关键):
系统提示词只注入技能清单(name + description,几十个 token)
│
▼ 模型判断"这个需求匹配 weekly-report 技能"
│
调用 load_skill("weekly-report") 工具 → 加载完整正文(几百 token,按需才花)
① SKILL.md 格式(skills/weekly-report/SKILL.md):
---
name: weekly-report
description: 生成周报。当用户要求写周报、总结本周工作时使用。
version: 1.0.0
---
# 周报生成技能
**② 技能服务**([skills.service.ts](../code/02-langchain-module/src/skills/skills.service.ts)):启动时扫描 `SKILLS_DIR`,解析 frontmatter(js-yaml),缺 description 的技能跳过;`buildPromptSection()` 生成注入系统提示词的清单段落;`getSkillBody(name)` 供 load_skill 工具取正文。
**③ 挂载到 Agent**(chat.service.ts):
```typescript
tools.push(tool(
async ({ name }) => skillsService.getSkillBody(name),
{ name: 'load_skill', description: '按名称加载技能的完整说明', schema: z.object({ name: z.string() }) },
));
// 系统提示词 += skillsService.buildPromptSection()
curl http://localhost:3102/api/skills # 看到 weekly-report / sql-review
# 触发技能:
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' \
-d '{"message":"帮我写周报:本周完成了登录模块和支付联调,支付还有 2 个 bug"}'
# 返回的 toolCalls 中可见 load_skill 被调用,回答按技能规定的结构输出
--- 必须顶格、成对出现,YAML 缩进错误会导致解析失败(技能被静默跳过,看启动日志)。code-review 技能:按"可读性/健壮性/性能"三维度审查代码。enabled: false frontmatter 开关,加载时过滤。tool() + zod 封装本地 Nest 能力createReactAgent 组装 Agent 并观察工具调用轨迹ReAct 循环(Reason + Act):
用户提问
▼
模型思考:"要回答这个问题,我需要先查会话统计"
▼ 生成 tool_call: get_chat_stats()
框架执行工具(查数据库)→ 把结果作为 ToolMessage 回填
▼
模型基于真实数据组织最终回答
关键点:工具回调里可以直接注入 Nest 的 Service/Repository——大模型就这样接入了你的业务系统。
① 封装本地工具(local-api.tools.ts):
const statsTool = tool(
async () => { // 回调里直接用注入的 Repository 查库
const conversations = await this.conversationRepo.count();
const messages = await this.messageRepo.count();
return JSON.stringify({ conversations, messages });
},
{
name: 'get_chat_stats',
description: '查询会话统计。用户询问"有多少会话/消息"时调用。', // 描述决定模型何时调用
schema: z.object({}), // zod 描述参数
},
);
② 组装 Agent 并执行(chat.service.ts):
const agent = createReactAgent({ llm: model, tools });
const result = await agent.invoke({ messages: context });
// 提取工具调用轨迹(可观测性)
const toolCalls = result.messages
.flatMap((m) => m.tool_calls ?? [])
.map((tc) => tc.name);
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' \
-d '{"message":"这个系统里现在有多少个会话和消息?"}'
# 返回中 toolCalls: ["get_chat_stats"],answer 包含真实数字
supportsTools 自动跳过。search_messages(keyword):按关键词搜索历史消息。大模型没有记忆——每次请求都是独立的,所谓"多轮对话"是你把历史消息一起传过去。于是有三个问题:
发给模型的上下文 =
[SystemMessage:系统设定 + 历史摘要(长期记忆)]
+ [最近 6 轮原始消息(短期记忆)]
+ [本轮用户消息]
① 实体:conversations(含 summary 字段)+ messages(role/content/model),见 conversation.entity.ts / message.entity.ts。
② 窗口管理(memory.service.ts)——双重截断:
// 轮数上限:最近 6 轮
const recent = await this.messageRepo.find({
where: { conversationId }, order: { createdAt: 'DESC' }, take: WINDOW_ROUNDS * 2,
});
// 字符预算:从最新往前累加,超 12000 字符就停(粗略 token 估算)
for (let i = recent.length - 1; i >= 0; i--) {
if (budget - m.content.length < 0 && selected.length > 0) break;
budget -= m.content.length;
selected.unshift(m);
}
③ 摘要压缩(summarizer.service.ts):消息超过 20 条时,用轻量模型把较早的消息压成 ≤200 字摘要,滚动合并进 conversation.summary。异步执行,不阻塞对话。
# 两轮对话验证记忆
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' -d '{"message":"记住:我的幸运数字是 7"}'
# 记下返回的 conversationId
curl -X POST http://localhost:3102/api/chat/completions \
-H 'Content-Type: application/json' \
-d '{"message":"我的幸运数字是多少?","conversationId":"<上一步的 id>"}'
# 模型应答出 7
# 查看持久化的历史
curl http://localhost:3102/api/chat/conversations/<id>
createdAt + id 双字段排序更稳。类比:非流式像等厨师把整桌菜做完一起上;流式像火锅——食材一盘盘上,体验立刻开始。对话产品必须流式,否则用户干等十几秒。
两种推送通道:
| SSE | WebSocket | |
|---|---|---|
| 方向 | 服务器单向推 | 双向 |
| 协议 | 普通 HTTP | 独立长连接协议 |
| 中断生成 | 断开连接 | 发个事件即可 |
| 适用 | 简单问答流 | 聊天 UI、需要中断 |
LangChain 侧:agent.streamEvents({…}, { version: 'v2' }) 产出细粒度事件流,监听 on_chat_model_stream 事件即可拿到每个 token。
① 服务端产出 token 流(chat.service.ts):
async *stream(dto: ChatDto): AsyncGenerator<{ type: 'token'|'done'|'meta'; data: string }> {
// …路由、上下文准备同同步版…
const stream = await agent.streamEvents({ messages: context }, { version: 'v2' });
for await (const event of stream) {
if (event.event === 'on_chat_model_stream') {
const text = typeof event.data?.chunk?.content === 'string' ? event.data.chunk.content : '';
if (text) yield { type: 'token', data: text };
}
}
yield { type: 'done', data: conversationId };
}
② SSE 端点(chat.controller.ts):
@Post('chat/stream')
async stream(@Body() dto: ChatDto, @Res() res: Response): Promise<void> {
// SSE 三件套响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
for await (const chunk of this.chatService.stream(dto)) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`); // SSE 报文格式
}
res.write('data: [DONE]\n\n'); // 结束标记
res.end();
}
关键坑点:用
@Res()手动写响应,天然绕过全局"统一响应包装"拦截器。若用全局拦截器包装了 SSE 端点,拦截器会等响应结束才输出——流式效果完全失效。
③ WebSocket 流式 + 中断(chat.gateway.ts):
@SubscribeMessage('chat:send')
async handleChat(client: Socket, payload: WsChatPayload) {
const flag = { stopped: false };
this.stopFlags.set(client, flag);
for await (const chunk of this.chatService.stream(dto)) {
if (flag.stopped) break; // 客户端 emit 'chat:stop' 即中断
client.emit(`chat:${chunk.type}`, chunk.data);
}
}
# SSE:-N 关闭缓冲,观察逐字蹦出
curl -N -X POST http://localhost:3102/api/chat/stream \
-H 'Content-Type: application/json' -d '{"message":"写一首关于春天的五言绝句"}'
# WebSocket:见 README 的 ws-ai-test.js
-N(禁用缓冲)。proxy_buffering off;。{ version: 'v2' }。event: 字段区分 token/meta/done(而不是都塞在 data 里)。/chat/stream(提示:EventSource 只支持 GET,可另做一个 GET 版端点)。@langchain/core / @langchain/openai / @langchain/langgraph 各自的职责?ChatOpenAI 接入?关键配置是哪一项?下一阶段:阶段 3:PostgreSQL 进阶与向量检索 RAG —— pgvector、向量检索与 RAG 全流程。
02-langchain-module/
.env.example
.gitignore
README.md
mcp.example.json
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
skills/
sql-review/
SKILL.md
weekly-report/
SKILL.md
src/
app.module.ts
main.ts
chat/
chat.controller.ts
chat.gateway.ts
chat.module.ts
chat.service.ts
config/
env.validation.ts
llm/
llm.module.ts
model-factory.service.ts
model-registry.ts
model-router.service.ts
params.service.ts
mcp/
mcp-client.service.ts
mcp-server.service.ts
mcp.controller.ts
memory/
conversation.entity.ts
memory.service.ts
message.entity.ts
summarizer.service.ts
skills/
skills.service.ts
tools/
local-api.tools.ts配套代码:../code/03-pg-vector-rag/(完整可运行项目) 技术栈:PostgreSQL 16 + pgvector + TypeORM 0.3 + LangChain.js 1.x 建议学习时长:3~4 天(每天 2~3 小时)
EXPLAIN ANALYZE 读执行计划阶段 1 已掌握 CRUD 与事务,本章补齐三块进阶:
① QueryBuilder 复杂查询:
// 子查询 + 聚合 + 分组:查询发过文章的用户及其文章数
const rows = await this.repo.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.addSelect('COUNT(p.id)', 'postCount')
.leftJoin('u.posts', 'p')
.groupBy('u.id')
.having('COUNT(p.id) > :min', { min: 0 })
.orderBy('postCount', 'DESC')
.getRawMany();
② 事务隔离级别:PG 默认 Read Committed。需要更强一致性时:
await this.dataSource.transaction('SERIALIZABLE', async (manager) => { /* ... */ });
③ 索引与执行计划:B-Tree 索引像字典的目录页,把 O(n) 全表扫描变成 O(log n) 定位。验证索引是否生效:
EXPLAIN ANALYZE SELECT * FROM document_chunks WHERE source = 'help-center.md';
-- Index Scan using ... → 命中索引;Seq Scan → 全表扫描
docker exec -it tutorial-pg psql -U tutorial -d tutorial \
-c "EXPLAIN ANALYZE SELECT * FROM document_chunks WHERE source = 'help-center.md';"
WHERE date(created_at) = ... 改为范围查询 created_at >= ... AND created_at < ...。document_chunks.source 建 B-Tree 索引并用 EXPLAIN 验证。什么是向量检索? 类比:把每段文字交给 Embedding 模型变成一个坐标点(几百~几千维的数字数组)。语义相近的文字,坐标也相近。"找相关内容"就变成了"找距离最近的点"——这就是向量检索。
"如何退款" → [0.12, -0.45, 0.78, ...] ┐ 距离近 ✓
"怎么申请退款" → [0.11, -0.43, 0.80, ...] ┘
"明天天气" → [-0.66, 0.31, -0.02, ...] 距离远 ✗
安装(三选一,详见 scripts/init-pgvector.sql):
# 方式 A(推荐):Docker 镜像自带
docker run -d --name tutorial-pg -e POSTGRES_USER=tutorial \
-e POSTGRES_PASSWORD=tutorial123 -e POSTGRES_DB=tutorial \
-p 5432:5432 pgvector/pgvector:pg16
# 方式 B:macOS brew
brew install postgresql@16 pgvector
# 方式 C:Linux 源码编译(需 postgresql-server-dev-16、build-essential)
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
cd pgvector && make && sudo make install
CREATE EXTENSION IF NOT EXISTS vector; -- 每个数据库执行一次
TypeORM 中定义向量列:TypeORM 没有原生 vector 类型,教程统一方案——float8 数组列 + 查询时 ::vector 转换(document.entity.ts):
@Column({ name: 'embedding', type: 'float8', array: true })
embedding!: number[];
-- 检索 SQL 中 cast 成 vector 再算距离
SELECT id, 1 - (embedding::vector <=> $1::vector) AS similarity
FROM document_chunks
ORDER BY embedding::vector <=> $1::vector
LIMIT 5;
三个距离算子:
| 算子 | 含义 | 适用 |
|---|---|---|
<-> |
L2(欧氏)距离 | 通用 |
<#> |
负内积 | 向量已归一化时 |
<=> |
余弦距离 | 文本语义检索首选(只看方向不看长度) |
ANN 索引:精确检索是 O(n) 逐个比,百万级数据扛不住。近似最近邻索引用"少量精度换大幅提速":
| IVFFlat | HNSW | |
|---|---|---|
| 原理 | 先聚类分桶,查询只扫附近的桶 | 多层跳表图,逐层逼近目标 |
| 建索引 | 快 | 慢(3~10 倍) |
| 查询精度 | 中 | 高 |
| 增量写入 | 差 | 好 |
完整建索引 SQL 与参数说明见 scripts/create-index.sql。
docker exec -i tutorial-pg psql -U tutorial -d tutorial < scripts/init-pgvector.sql
# 输出 vector 扩展版本号即成功
type "vector" does not exist:忘了 CREATE EXTENSION vector,或建在了别的数据库里(扩展是按库生效的)。different vector dimensions:查询向量与库中向量维度不同。换 Embedding 模型必须清空重入库。postgresql-server-dev-16,或 pg_config 不在 PATH。① Embedding 统一接口(embedding.service.ts):
// env 一键切换:EMBEDDING_MODE=qwen(在线)| local(本地离线)
get mode(): EmbeddingMode { ... }
async embed(text: string): Promise<number[]> { ... }
async embedBatch(texts: string[]): Promise<number[][]> { ... }
OpenAIEmbeddings + DashScope 兼容端点,text-embedding-v3(1024 维);@huggingface/transformers 跑 bge-small-zh(512 维),mean pooling + L2 归一化;动态 import 懒加载,只有本地模式才加载这个大包。国内下载慢设 HF_ENDPOINT=https://hf-mirror.com。② 分片(chunker.service.ts):
new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每片约 500 字:太小缺上下文,太大语义稀释
chunkOverlap: 80, // 相邻片重叠 80 字:避免关键句被切断
separators: ['\n\n', '\n', '。', '!', '?', ';', ' '], // 中文友好的切分优先级
});
③ 批量写入(vector.service.ts):分批 save(每批 100 条,避开 PG 65535 参数上限),每条记录 embeddingModel 与 embeddingDim 做维度追溯。
curl http://localhost:3103/api/rag/stats
# { chunkCount, embeddingMode, embeddingModel }
RAG = 开卷考试:模型先"翻书"(检索知识库)找到相关段落,再基于这些段落组织答案。它解决两个痛点:
用户提问
│
▼ ① 查询向量化(同一个 Embedding 模型!)
▼ ② 向量检索 Top-K 分片
▼ ③ 把分片塞进 Prompt:【参考资料】+【问题】
▼ ④ LLM 依据资料作答(要求标注引用编号)
带引用的答案
① 入库管线(ingestion.service.ts):解析(document.parser.ts:pdf-parse / mammoth / 纯文本)→ 分片 → embedBatch → insertChunks。
② 问答管线(rag.service.ts)核心 Prompt:
const prompt = ChatPromptTemplate.fromMessages([
['system', `你是知识库问答助手。只依据【参考资料】回答问题:
- 资料中有答案时,给出准确回答,并在句末用 [编号] 标注引用;
- 资料中没有答案时,明确说"知识库中未找到相关内容",不要编造;`],
['human', '【参考资料】\n{context}\n\n【问题】\n{question}'],
]);
const chain = prompt.pipe(model);
await chain.invoke({ context, question });
教学降级设计:未配置
LLM_API_KEY时,/rag/ask直接返回召回的资料原文,保证没有 Key 也能验证检索管线。
# 上传示例文档 → 提问 → 看引用
curl -X POST http://localhost:3103/api/rag/ingest -F "file=@sample-docs/help-center.md"
curl -X POST http://localhost:3103/api/rag/ask \
-H 'Content-Type: application/json' -d '{"question":"退款多久能到账?"}'
# answer 中应出现"3 个工作日",citations 指向 help-center.md 的对应分片
# 知识库外问题 → 不幻觉
curl -X POST http://localhost:3103/api/rag/ask \
-H 'Content-Type: application/json' -d '{"question":"明天天气怎么样"}'
基础 RAG 的四个典型病症与药方:
| 病症 | 原因 | 药方 |
|---|---|---|
| 召回的片段"差一点" | 分片切断了上下文 | 父子块:小块检索、大块作答 |
| 专有名词/编号搜不到 | 向量对字面不敏感 | 多路召回:向量 + 关键词,RRF 融合 |
| 用户提问口语化 | "这玩意儿咋退款" | 查询改写 / HyDE |
| 召回了但不相关排前面 | Top-K 只按相似度 | 重排:LLM 对相关性打分 |
① 父子块(chunker.service.ts splitParentChild):父块 2000 字、子块 200 字。子块带向量用于检索,命中后通过 parentId 取父块给模型。
② 多路召回 + RRF(retriever.service.ts):
// 两路并行
const [vectorHits, keywordHits] = await Promise.all([
this.vectorRetrieve(query, topK * 2), // 语义路
this.vectorService.searchKeyword(query, topK * 2), // 字面路(tsvector)
]);
// RRF 融合:score(d) = Σ 1/(60 + rank),只看排名不看分数,规避量纲问题
③ 查询改写与 HyDE(rewrite.service.ts):改写=把口语问题变成检索友好的关键词句;HyDE=先让 LLM 写一段"假想答案",用它去检索(假想答案与真实文档分布更接近)。
④ LLM 重排(rerank.service.ts):召回 topK×3,逐个让 LLM 打 0~10 分,取前 topK。
# 口语化提问 + 全优化开启,观察 pipeline 字段记录的每一步
curl -X POST http://localhost:3103/api/rag/ask \
-H 'Content-Type: application/json' \
-d '{"question":"这玩意儿咋退款啊","rewrite":true,"hybrid":true,"rerank":true}'
simple 配置按字分词,够用但不精准;生产建议 zhparser/pg_jieba。// zod schema 描述期望输出
const schema = z.object({
title: z.string().describe('内容标题(10 字以内)'),
summary: z.string().describe('内容摘要(50 字以内)'),
keywords: z.array(z.string()).describe('3~5 个关键词'),
tags: z.array(z.enum(['技术', '产品', '运营', '制度', '教程', '其他'])),
});
// withStructuredOutput:输出被强制约束为 schema 形状,免去手工解析 JSON
const structured = model.withStructuredOutput(schema);
const result = await structured.invoke(`请分析下面的文本并提取信息:\n\n${text}`);
清洗用纯规则(去控制字符、归一空白),不浪费 token。批处理逐条执行、失败跳过不阻塞整批。
curl -X POST http://localhost:3103/api/enhance/extract \
-H 'Content-Type: application/json' \
-d '{"text":"星云商城双十一战报:当日 GMV 突破 5 亿元,同比增长 32%……"}'
① 建索引前后对比:
npm run benchmark # 无索引基线
docker exec -i tutorial-pg psql -U tutorial -d tutorial < scripts/create-index.sql
npm run benchmark # HNSW 索引后对比
② HNSW 调参(create-index.sql):
| 参数 | 阶段 | 默认 | 调大的效果 |
|---|---|---|---|
m |
建索引 | 16 | 连接更多→更准,更占内存 |
ef_construction |
建索引 | 64 | 建得更细→索引质量高,建得慢 |
hnsw.ef_search |
查询 | 40 | 搜得更广→更准,更慢 |
③ 批量写入与分页:入库分批(每批 100);列表接口用 keyset 分页(WHERE id > :lastId ORDER BY id LIMIT n)替代大 OFFSET。
④ 读执行计划:
SET hnsw.ef_search = 100;
EXPLAIN ANALYZE SELECT id FROM document_chunks
ORDER BY embedding::vector <=> '[...]'::vector LIMIT 5;
/rag/search(不含 LLM),否则瓶颈在模型 API 而非数据库。SET hnsw.ef_search 分别设 20/100/200,观察延迟与召回结果变化。下一阶段:阶段 4:综合实战项目 —— 把三个阶段的全部能力整合成一个可上线的 AI 知识库问答系统。
03-pg-vector-rag/
.env.example
.gitignore
README.md
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
sample-docs/
help-center.md
scripts/
benchmark.ts
create-index.sql
init-pgvector.sql
src/
app.module.ts
main.ts
enhance/
data-enhance.service.ts
ingestion/
chunker.service.ts
ingestion.service.ts
rag/
rag.controller.ts
rag.service.ts
rerank.service.ts
retriever.service.ts
rewrite.service.ts
types/
pdf-parse.d.ts
vector/
document.entity.ts
vector.service.ts配套代码:../code/04-full-project/(完整可运行项目,含 Docker 部署) 建议学习时长:4~6 天(每天 2~3 小时)
本阶段把前三个阶段的全部能力整合成一个可上线的系统。学习目标不是学新知识,而是理解真实项目的模块划分、数据流与部署方式。
┌────────────────────────────── 接入层 ──────────────────────────────┐
│ REST (Swagger /api/docs) SSE 流式 WebSocket 实时对话网关 │
│ 全局:ValidationPipe / 统一响应拦截器 / 异常过滤器 / JWT Guard │
├────────────────────────────── 业务模块层 ──────────────────────────┤
│ AuthModule(注册/登录/刷新) KnowledgeModule(知识库管理) │
│ ChatModule(问答会话) GatewayModule(WS 网关) │
├────────────────────────────── AI 能力层 ───────────────────────────┤
│ LlmService(模型工厂+路由) RagService(多路召回+RRF) │
│ MemoryService(上下文窗口) SkillsService + BusinessTools │
│ EmbeddingService(在线千问/本地 bge) │
├────────────────────────────── 数据层 ──────────────────────────────┤
│ PostgreSQL 16:users / knowledge_bases / conversations / messages │
│ + document_chunks(float8 数组 + pgvector 检索) │
└────────────────────────────────────────────────────────────────────┘
数据表关系:
users 1 ── N knowledge_bases (用户的知识库)
users 1 ── N conversations 1 ── N messages (对话历史)
knowledge_bases 1 ── N document_chunks (知识库的分片向量)
conversations N ── 1 knowledge_bases(可空,问答范围绑定)
| # | 功能 | 模块 | 来自阶段 |
|---|---|---|---|
| 1 | 注册登录、JWT 双 Token、刷新轮换 | src/auth/ |
阶段 1 |
| 2 | 知识库 CRUD、文档上传自动入库 | src/knowledge/ src/ingestion/ |
阶段 3 |
| 3 | RAG 问答:多路召回 + Prompt 注入 + 引用 | src/chat/rag.service.ts |
阶段 3 |
| 4 | 多模型路由:简单→轻量,知识库问答→强模型 | src/llm/ |
阶段 2 |
| 5 | 对话历史持久化 + 多轮上下文窗口 | src/chat/memory.service.ts |
阶段 2 |
| 6 | SSE / WebSocket 流式输出 + 中断 | src/chat/chat.controller.ts src/gateway/ |
阶段 1+2 |
| 7 | 工具调用:业务查询 Tool + SKILL.md 技能 | src/tools/ src/skills/ |
阶段 2 |
| 8 | Swagger 文档 + Docker 部署 | src/main.ts Dockerfile |
阶段 1+4 |
整合时的关键设计决策(读代码时重点看):
document_chunks 带 knowledge_base_id,检索 SQL 加 WHERE knowledge_base_id = $2——不同知识库互不串扰;conversations.knowledge_base_id 可空。绑定 → 每轮问答自动 RAG(complex=true → 强模型);不绑定 → 纯闲聊(轻量模型);@Res() 手动写响应;assertOwner / getOwnedConversation),防越权。用户 POST /api/chat/completions { message, conversationId }
│
▼ JwtAuthGuard 验签 → 得到 userId
▼ 取会话(校验归属,拿到绑定的 knowledgeBaseId)
│
├─ 有知识库 → RagService.retrieve:
│ 查询向量化 → 向量召回 + 关键词召回 → RRF 融合 Top-3
│ 拼成【参考资料】
│
▼ LlmService.route:有参考资料 → 强模型;否则轻量模型
▼ MemoryService.buildContext:系统提示 + 技能清单 + 资料 + 最近 6 轮
▼ createReactAgent.invoke(工具:业务查询 + load_skill)
│ 模型可能先调工具查真实数据,再组织答案
▼ 持久化 user/assistant 两条消息
│
返回 { answer, model, routeReason, conversationId, citations }
完整 curl 命令见 code/04-full-project/README.md,主线是:
注册 → 登录 → 建知识库 → 上传文档 → 建会话(绑定知识库)
→ 同步问答(看引用) → 流式问答 → 工具调用 → 查会话历史
验证要点:
citations 应指向上传文档的分片;routeReason 应显示"复杂问答→强模型"(绑定知识库时)或"简单问答→轻量模型"(纯闲聊);get_my_knowledge_bases 工具并返回真实数据。① 镜像构建(Dockerfile):多阶段构建——builder 阶段编译 TS,运行阶段只带 dist + 生产依赖,镜像更小。
② 编排(docker-compose.yml):
services:
postgres:
image: pgvector/pgvector:pg16 # 自带 pgvector
volumes:
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # 首启自动 CREATE EXTENSION
healthcheck: ... # pg_isready 探活
app:
build: .
depends_on:
postgres:
condition: service_healthy # 数据库就绪后才启动应用
environment:
DB_HOST: postgres # 容器网络内用服务名,不是 127.0.0.1!
③ 部署命令:
cd code/04-full-project
export DEEPSEEK_API_KEY=sk-xxx # 可选
docker compose up -d --build
docker compose logs -f app # 观察启动日志
curl http://localhost:3104/api/docs # 验证
④ 常见部署问题:
DB_HOST 是否为服务名 postgres;"3105:3104";docker compose up -d --build 重新构建。| 模块 | 端点 | 方法 | 鉴权 |
|---|---|---|---|
| 认证 | /api/auth/register |
POST | 无 |
| 认证 | /api/auth/login |
POST | 无 |
| 认证 | /api/auth/refresh |
POST | 无 |
| 认证 | /api/auth/profile |
GET | JWT |
| 知识库 | /api/knowledge-bases |
POST / GET | JWT |
| 知识库 | /api/knowledge-bases/:id/documents |
POST(上传) | JWT + 归属 |
| 知识库 | /api/knowledge-bases/:id/chunks |
GET | JWT + 归属 |
| 知识库 | /api/knowledge-bases/:id |
DELETE | JWT + 归属 |
| 对话 | /api/chat/completions |
POST | JWT |
| 对话 | /api/chat/stream(SSE) |
POST | JWT |
| 对话 | /api/chat/models |
GET | JWT |
| 对话 | /api/chat/conversations |
POST / GET | JWT |
| 对话 | /api/chat/conversations/:id |
GET / DELETE | JWT + 归属 |
| WS | ws://host/chat |
事件:chat:send/token/meta/done/error/stop | 握手 token |
| 文档 | /api/docs |
GET | 无 |
安全检查:
openssl rand -hex 32 生成的随机值;.env 不在镜像/git 中(.dockerignore / .gitignore 已配置);synchronize(NODE_ENV=production 时自动关闭),改用迁移脚本;稳定性:
常见问题:
DB_HOST 为什么是 postgres 而不是 127.0.0.1?🎉 恭喜完成全部四个阶段! 你现在已经拥有一个完整的 AI 知识库问答后端系统,以及从 NestJS 工程化、LangChain 服务化到向量检索 RAG 的完整知识体系。接下来可以尝试:接入前端聊天 UI、增加文档解析格式、接入 rerank 专用模型、把压测纳入 CI。
04-full-project/
.dockerignore
.env.example
.gitignore
Dockerfile
README.md
docker-compose.yml
nest-cli.json
package-lock.json
package.json
tsconfig.build.json
tsconfig.json
docker/
init.sql
skills/
kb-qa/
SKILL.md
src/
app.module.ts
main.ts
auth/
auth.controller.ts
auth.module.ts
auth.service.ts
current-user.decorator.ts
jwt-auth.guard.ts
jwt.strategy.ts
user.entity.ts
chat/
chat.controller.ts
chat.module.ts
chat.service.ts
memory.service.ts
rag.service.ts
common/
config/
env.validation.ts
gateway/
chat.gateway.ts
gateway.module.ts
ingestion/
document.parser.ts
ingestion.service.ts
knowledge/
knowledge-base.entity.ts
knowledge.controller.ts
knowledge.module.ts
llm/
llm.module.ts
llm.service.ts
model-registry.ts
skills/
skills.service.ts
tools/
business-tools.service.ts
types/
pdf-parse.d.ts
vector/
document.entity.ts
embedding.service.ts
vector.service.ts