2026 前端 AI Agent 工程化实战营系列第 6 篇

本文整理自《2026 前端 AI Agent 工程化实战营》课程资料,作为系列文章第 6 篇。

cleaned-image_(3).png

在第四章,我们已经把 Memory、Tools、Embeddings 和 Multi-Agent 全部跑通了,但这些都只是 demo 级示例:所有数据都存在于进程内存或本地文件中。InMemoryChatMessageHistory 重启即丢MemoryVectorStore 进程退出即清空workspace/orders/*.json 是手写的 mock 数据,更谈不上用户隔离——无论谁来,都是同一个匿名会话

在能力验证阶段或者是学习使用,这完全没问题。但一旦你打算让这套系统真正服务多个用户、长期运行,并且支持历史回溯,就必须把数据从内存迁移到数据库,把文件从本地目录迁移到可管理的存储层,把向量从临时容器迁移到可持久检索的引擎中。

本章将从这里出发,逐步把所有示例以生产形态落地,包括数据库设计与全流程串联等内容。

同时,本章有一个重要的前提假设:用户系统已经就位。如果你跟着"AI 搞定发布系统"那篇文章搭过 RBAC 权限模块,那么 JWT 鉴权、用户表、角色权限这些基础设施已经可以直接复用。本章不会重新搭用户系统,而是在已有用户体系的基础上,为每个用户建立独立的会话、文档和向量空间。

再次声明:请不要直接写代码。所有流程都借助上一篇文章中的 Claude + Superpowers(或其他支持 Spec 模式的 AI IDE)来补齐功能。

我们会把迭代过程控制在可预期的范围内。你需要学习的是系统架构,而不是按部就班地把代码抄写一遍。虽然你仍然需要了解其中的细节、具体的方法,以及 API 的使用方式,但不要再把时间花在具体的编码细节上。给自己招一个 AI 小打杂的,解放生产力。


5.1 从 Demo 到生产,还差什么

在第四章中,我们已经把所有流程都跑通了,但距离真正上线仍有明显差距:

能力 第四章实现 生产级问题 本章方案
会话记忆 InMemoryChatMessageHistory 重启丢失,无用户隔离 PostgreSQL + Prisma 持久化
向量存储 MemoryVectorStore 进程退出即清空,需重新灌库 pgvector 扩展,持久化检索
业务数据 workspace/*.json 手写文件 无法动态新增,无权限控制 文件上传 + 数据库元数据
用户体系 所有人共享同一上下文 复用 RBAC 用户系统,JWT 鉴权
工单制品 tickets/*.md 本地文件 无法检索,无法关联用户 分析结果入库,关联会话与用户

虽然内容看起来很多,但归纳起来,核心差距主要有三点:

本章逐一补齐这些缺口,把每一项能力从"能跑通"推进到"能上线"。


5.2 系统架构总览

在动手写 Schema 之前,先把整条链路的全貌画清楚:这是本章的“总架构框架”。它的价值不在于画得多漂亮,而在于先把边界、责任和数据流向讲清楚,避免后续实现时各节各写各的,最后拼不回一个能上线的系统。 具体来说,总架构框架能帮助你纠正三类常见偏差:

  1. 目标偏差:每一节都在“加功能”,却不清楚最终要交付的端到端体验是什么。先把“登录 → 会话 → 文档 → 检索 → 分析 → 落库”的闭环定下来,才能保证每一步都有明确的验收点。
  2. 接口偏差:Schema、Service、Controller、LangChain 链路如果各自定义术语和参数,后续就会出现大量返工。先明确核心对象(userId、conversationId、documentId、chunkId)及其关系,后面实现就能围绕这些接口收敛。
  3. 数据偏差:容易出现“存了但用不上”“用了但没落库”“检索没做隔离”等问题。先把数据从哪里来、在哪里处理、最终落在哪里讲清楚,才能确保持久化、用户隔离与可追溯性贯穿全章。 所以你可以把这张总览图当作本章的路线图:后面每一节都只是在把图中的某一个环节从 demo 形态替换成生产形态,并把它重新接回这条主链路。

cleaned-image_(3) 1.png

整条链路可以拆成四个关键环节:

  1. 用户鉴权:复用已有的 RBAC 用户系统,通过 JWT 识别当前用户
  2. 文档管理:上传 → 解析 → 分块 → 向量化 → 落库,建立用户私有的知识库
  3. 会话对话:创建会话 → 发送消息 → 加载历史 → 语义检索 → Multi-Agent 分析 → 结果持久化
  4. 统一存储:所有数据(用户、会话、消息、文档、向量)都落在同一个 PostgreSQL 实例中

5.3 数据库设计:Prisma Schema 全貌

5.3.1 整体数据模型

先看全貌。本章的数据模型由五张核心表组成,它们之间的关系很直接:

cleaned-image_(3) 2.png

每张表的设计意图:

5.3.2 Prisma Schema 定义

// services/api/prisma/schema.prisma

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["postgresqlExtensions"]
}

datasource db {
  provider   = "postgresql"
  url        = env("DATABASE_URL")
  extensions = [pgvector]
}

model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String?
  password      String
  role          String         @default("user")
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt
  conversations Conversation[]
  documents     Document[]
}

model Conversation {
  id        String    @id @default(cuid())
  title     String    @default("新会话")
  userId    String
  user      User      @relation(fields: [userId], references: [id])
  messages  Message[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  @@index([userId])
}

model Message {
  id             String       @id @default(cuid())
  conversationId String
  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
  role           String       // system | human | ai | tool
  content        String       @db.Text
  metadata       Json?
  createdAt      DateTime     @default(now())

  @@index([conversationId])
}

model Document {
  id           String          @id @default(cuid())
  userId       String
  user         User            @relation(fields: [userId], references: [id])
  filename     String          // 存储路径
  originalName String          // 原始文件名
  mimeType     String
  size         Int
  status       String          @default("pending") // pending | processing | completed | failed
  chunkCount   Int             @default(0)
  createdAt    DateTime        @default(now())
  updatedAt    DateTime        @updatedAt
  chunks       DocumentChunk[]

  @@index([userId])
}

model DocumentChunk {
  id         String   @id @default(cuid())
  documentId String
  document   Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
  content    String   @db.Text
  chunkIndex Int
  metadata   Json?
  embedding  Unsupported("vector(384)")?
  createdAt  DateTime @default(now())

  @@index([documentId])
}

5.3.3 关键设计决策说明

为什么 Message 使用 onDelete: Cascade

会话删除时,其下所有消息应一并清除。这符合最自然的业务语义——你不会希望数据库里残留一堆没有归属的孤儿消息。同理,DocumentChunk 也应级联删除。

为什么 Document 的 status 用字符串而不是枚举?

在教学阶段,字符串更直观,迁移也更灵活。生产环境可以考虑改为 Prisma 的 enum 类型,获得更强的类型约束。

为什么 embedding 字段标记为可选(?)?

因为文档上传和向量化是异步的两步操作。Chunk 创建时可能还没有完成向量化,所以 embedding 允许为空。

5.3.5 Prisma Service(全局模块)

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  constructor() {
    const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
    super({ adapter });
  }

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

🧪 验证步骤(对应 5.3)

# 执行迁移(确保 PostgreSQL 已启动且 .env 中配置了 DATABASE_URL)
cd services/chat
bunx prisma migrate dev --name init
bunx prisma generate

# 验证表结构
bunx prisma studio

验收标准:Prisma Studio 能打开,五张表结构正确显示;DocumentChunk 表包含 embedding 列。

image.png

注意:此截图里之所以少一张表,是因为实际的项目采用微服务架构。

具体可见项目实现https://github.com/Cookieboty/Autix,因此用户表维护在 User-system 中。


5.4 会话持久化:从 InMemoryHistory 到 PostgreSQL

第四章的 RunnableMemoryService 使用 Map<string, InMemoryChatMessageHistory> 来存储会话,简单直接,但进程重启后数据会全部丢失。本节要做的,是用 PostgreSQL 替换这个 Map。

5.4.1 从内存到数据库:变化了什么,不变的是什么

变化的是存储层:

不变的是调用方式:

这就是第四章强调"上层不依赖具体实现"的实际收益——底层存储换了,上层代码几乎不需要改动。

5.4.2 自定义 DatabaseChatMessageHistory

LangChain 的 BaseChatMessageHistory 要求实现 getMessages()addMessage() 两个核心方法。我们只需要把底层实现从内存操作换成 Prisma 操作即可。

// services/api/src/conversation/db-chat-history.ts
import {
  BaseChatMessageHistory,
} from '@langchain/core/chat_history';
import {
  BaseMessage,
  HumanMessage,
  AIMessage,
  SystemMessage,
  ToolMessage,
} from '@langchain/core/messages';
import { PrismaService } from '../prisma/prisma.service';

export class DatabaseChatMessageHistory extends BaseChatMessageHistory {
  lc_namespace = ['custom', 'chat_history'];

  constructor(
    private prisma: PrismaService,
    private conversationId: string,
  ) {
    super();
  }

  async getMessages(): Promise<BaseMessage[]> {
    const messages = await this.prisma.message.findMany({
      where: { conversationId: this.conversationId },
      orderBy: { createdAt: 'asc' },
    });

    return messages.map((msg) => {
      switch (msg.role) {
        case 'system':
          return new SystemMessage(msg.content);
        case 'human':
          return new HumanMessage(msg.content);
        case 'ai':
          return new AIMessage(msg.content);
        case 'tool':
          return new ToolMessage({
            content: msg.content,
            tool_call_id: (msg.metadata as any)?.tool_call_id ?? '',
          });
        default:
          return new HumanMessage(msg.content);
      }
    });
  }

  async addMessage(message: BaseMessage): Promise<void> {
    let role = 'human';
    if (message instanceof SystemMessage) role = 'system';
    else if (message instanceof AIMessage) role = 'ai';
    else if (message instanceof ToolMessage) role = 'tool';

    await this.prisma.message.create({
      data: {
        conversationId: this.conversationId,
        role,
        content:
          typeof message.content === 'string'
            ? message.content
            : JSON.stringify(message.content),
      },
    });
  }

  async clear(): Promise<void> {
    await this.prisma.message.deleteMany({
      where: { conversationId: this.conversationId },
    });
  }
}

5.4.3 会话服务:CRUD + 用户隔离

// services/api/src/conversation/conversation.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class ConversationService {
  constructor(private prisma: PrismaService) {}

  async create(userId: string, title?: string) {
    return this.prisma.conversation.create({
      data: { userId, title: title ?? '新会话' },
    });
  }

  async findByUser(userId: string) {
    return this.prisma.conversation.findMany({
      where: { userId },
      orderBy: { updatedAt: 'desc' },
      include: { _count: { select: { messages: true } } },
    });
  }

  async findById(conversationId: string, userId: string) {
    const conversation = await this.prisma.conversation.findUnique({
      where: { id: conversationId },
    });
    if (!conversation || conversation.userId !== userId) {
      throw new ForbiddenException('无权访问该会话');
    }
    return conversation;
  }

  async delete(conversationId: string, userId: string) {
    await this.findById(conversationId, userId); // 权限校验
    return this.prisma.conversation.delete({
      where: { id: conversationId },
    });
  }
}

5.4.4 与 RunnableWithMessageHistory 集成

// 关键集成代码片段
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { DatabaseChatMessageHistory } from './db-chat-history';

// 在 ChatService 中
private createWithHistory(chain: Runnable) {
  return new RunnableWithMessageHistory({
    runnable: chain,
    getMessageHistory: (sessionId: string) =>
      new DatabaseChatMessageHistory(this.prisma, sessionId),
    inputMessagesKey: 'input',
    historyMessagesKey: 'history',
  });
}

注意这里的关键变化:getMessageHistory 现在返回的是 DatabaseChatMessageHistory 而不是 InMemoryChatMessageHistory。但对于 RunnableWithMessageHistory 来说,这是完全透明的——它只关心方法签名一致,不关心底层存储是内存还是数据库。

🧪 验证步骤(对应 5.4)

# 创建会话
curl -X POST http://localhost:3001/api/conversations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "退货咨询测试"}'

# 在会话中发送消息
curl -X POST http://localhost:3001/api/conversations/$CONV_ID/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input": "我买的蓝牙耳机降噪效果不好,想退货"}'

# 重启服务后,验证历史不丢失
bun run dev:api
curl http://localhost:3001/api/conversations/$CONV_ID/messages \
  -H "Authorization: Bearer $TOKEN"

验收标准:重启服务后,GET /:id/messages 仍能返回之前的完整对话历史;不同用户无法访问对方的会话。

image 1.png


5.5 文件上传与存储

第四章的"文件"是预先放在 workspace/ 目录下的文件——无法动态新增,也没有归属关系。

这一节要实现的是:用户可以上传自己的文件,系统负责存储、记录元数据,并为后续的解析和向量化做好准备。

5.5.1 上传流程设计

sequenceDiagram
    participant U as 用户
    participant API as NestJS API
    participant FS as 文件系统
    participant DB as PostgreSQL

    U->>API: POST /api/documents/upload(multipart/form-data)
    API->>API: Multer 解析文件 + 校验类型/大小
    API->>FS: 保存到 uploads/{userId}/{timestamp}-{name}
    API->>DB: 创建 Document 记录(status: pending)
    API-->>U: 返回 Document 元数据

mermaid-diagram-2026-04-12-185649.png

5.5.2 文件上传服务

// services/api/src/document/document.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as fs from 'node:fs';
import * as path from 'node:path';

const ALLOWED_MIME_TYPES = [
  'text/plain',
  'text/markdown',
  'application/pdf',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const UPLOAD_DIR = path.join(process.cwd(), 'uploads');

@Injectable()
export class DocumentService {
  constructor(private prisma: PrismaService) {}

  async upload(userId: string, file: Express.Multer.File) {
    // 校验
    if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
      throw new BadRequestException(
        `不支持的文件类型: ${file.mimetype},仅支持 TXT/MD/PDF`
      );
    }
    if (file.size > MAX_FILE_SIZE) {
      throw new BadRequestException('文件大小不能超过 10MB');
    }

    // 存储
    const userDir = path.join(UPLOAD_DIR, userId);
    fs.mkdirSync(userDir, { recursive: true });

    const filename = `${Date.now()}-${file.originalname}`;
    const filePath = path.join(userDir, filename);
    fs.writeFileSync(filePath, file.buffer);

    // 元数据入库
    return this.prisma.document.create({
      data: {
        userId,
        filename: `${userId}/${filename}`,
        originalName: file.originalname,
        mimeType: file.mimetype,
        size: file.size,
        status: 'pending',
      },
    });
  }

  async findByUser(userId: string) {
    return this.prisma.document.findMany({
      where: { userId },
      orderBy: { createdAt: 'desc' },
      include: { _count: { select: { chunks: true } } },
    });
  }
}

5.5.3 上传路由

// services/api/src/document/document.controller.ts
import {
  Controller, Post, Get, Delete, Param,
  UseInterceptors, UploadedFile, Req,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { DocumentService } from './document.service';

@Controller('api/documents')
export class DocumentController {
  constructor(private documentService: DocumentService) {}

  @Post('upload')
  @UseInterceptors(FileInterceptor('file'))
  async upload(
    @UploadedFile() file: Express.Multer.File,
    @Req() req: any,
  ) {
    const userId = req.user.id; // 从 JWT Guard 中获取
    return this.documentService.upload(userId, file);
  }

  @Get()
  async list(@Req() req: any) {
    return this.documentService.findByUser(req.user.id);
  }
}

🧪 验证步骤(对应 5.5)

# 上传文件
curl -X POST http://localhost:3001/api/documents/upload \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@workspace/policies/return-policy.md"

# 获取文档列表
curl http://localhost:3001/api/documents \
  -H "Authorization: Bearer $TOKEN"

验收标准:上传成功返回 Document 记录,statuspendinguploads/ 目录下能找到对应文件;文档列表接口返回用户已上传的文件。

image 2.png

image 3.png


5.6 文件解析与分块策略

文件上传之后,它只是一个静态文件——系统还不能"理解"它的内容。这一节要做的是:把文件变成可被向量化的文本块。

5.6.1 解析 → 分块 → 入库的三步流程

flowchart LR
    A["原始文件"] --> B["解析器(按类型)"]
    B --> C["纯文本"]
    C --> D["RecursiveCharacterTextSplitter"]
    D --> E["文本块数组"]
    E --> F["写入 DocumentChunk 表"]

mermaid-diagram-2026-04-12-173851.png

5.6.2 文件解析器

不同的文件类型需要采用不同的解析方式:

import { parseText } from './text.parser';
import { parsePdf } from './pdf.parser';
import { parseDocx } from './docx.parser';

export async function extractText(
  filePath: string,
  mimeType: string,
): Promise<string> {
  switch (mimeType) {
    case 'application/pdf':
      return parsePdf(filePath);
    case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
    case 'application/msword':
      return parseDocx(filePath);
    case 'text/plain':
    case 'text/markdown':
    case 'text/x-markdown':
      return parseText(filePath);
    default:
      return parseText(filePath);
  }
}

5.6.3 分块服务

// services/api/src/document/chunk.service.ts
import { Injectable } from '@nestjs/common';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { PrismaService } from '../prisma/prisma.service';
import { parseFile } from './parsers/parser.factory';

@Injectable()
export class ChunkService {
  private splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
  });

  constructor(private prisma: PrismaService) {}

  async chunkDocument(documentId: string) {
    const doc = await this.prisma.document.findUniqueOrThrow({
      where: { id: documentId },
    });

    // 更新状态
    await this.prisma.document.update({
      where: { id: documentId },
      data: { status: 'processing' },
    });

    try {
      // 1. 解析文件
      const text = await parseFile(doc.filename, doc.mimeType);

      // 2. 分块
      const chunks = await this.splitter.createDocuments([text]);

      // 3. 写入数据库
      await this.prisma.documentChunk.createMany({
        data: chunks.map((chunk, index) => ({
          documentId,
          content: chunk.pageContent,
          chunkIndex: index,
          metadata: chunk.metadata,
        })),
      });

      // 4. 更新文档状态
      await this.prisma.document.update({
        where: { id: documentId },
        data: {
          status: 'completed',
          chunkCount: chunks.length,
        },
      });

      return { chunkCount: chunks.length };
    } catch (error) {
      await this.prisma.document.update({
        where: { id: documentId },
        data: { status: 'failed' },
      });
      throw error;
    }
  }
}

🧪 验证步骤(对应 5.6)

# 触发文件处理(假设上一步上传返回的 Document ID 为 $DOC_ID)
curl -X POST http://localhost:3001/api/documents/$DOC_ID/process \
  -H "Authorization: Bearer $TOKEN"

# 验证分块结果
bunx prisma studio
# 检查 DocumentChunk 表,应有多条记录,每条包含 content 和 chunkIndex

验收标准:处理完成后 Document 的 status 变为 completedchunkCount 大于 0;DocumentChunk 表中有对应的分块记录。

image 4.png

image 5.png


5.7 向量化落库:从 MemoryVectorStore 到 pgvector

分块完成后,每个 chunk 只是一段纯文本——还不能做语义检索。这一节要做的,是把第四章的 MemoryVectorStore 替换为 pgvector,让向量真正持久化到数据库中。

5.7.1 向量化服务

import { Injectable } from '@nestjs/common';
import { pipeline, mean_pooling } from '@xenova/transformers';

@Injectable()
export class EmbeddingService {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private embedder: any = null;
  private readonly modelName = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';

  /** 延迟初始化 pipeline(模型下载一次,后续复用) */
  private async getEmbedder() {
    if (!this.embedder) {
      this.embedder = await pipeline('feature-extraction', this.modelName);
    }
    return this.embedder;
  }

  /**
   * 将一组文本转为向量
   * @param texts 原文列表
   * @returns 向量列表,每项长度为 384
   */
  async embedTexts(texts: string[]): Promise<number[][]> {
    const embedder = await this.getEmbedder();
    // 换行符会影响 token 切分,统一替换为空格
    const cleanTexts = texts.map((t) => t.replace(/\n/g, ' '));

    // Step 1: 提取原始隐藏状态 (batch, seq_len, hidden)
    const rawOutput = await embedder(cleanTexts) as any;
    console.log('[Embedding] raw output shape:', rawOutput.dims, 'type:', rawOutput.type);

    // Step 2: 获取 token attention mask,手动做 mean pooling
    //         attention_mask 中 1 = 真实 token,0 = padding
    const tokenizer = embedder.tokenizer;
    const inputs = tokenizer(cleanTexts, { padding: true, truncation: true });
    const pooled = mean_pooling(rawOutput, inputs.attention_mask);

    // Step 3: L2 单位化,使余弦相似度 = 向量点积
    const normalized = pooled.normalize(2, -1);

    return normalized.tolist();
  }
}

5.7.2 语义检索服务(带用户隔离)

这是与第四章最关键的差异之一:检索结果不再是全局的,而是只返回当前用户自己上传的文档。

如果想做得更完善,可以引入组织的概念,或者在上传时创建公共共享库,以支持多人共享文档。当然,这时你的 prompt 也需要相应调整,不能再沿用当前的写法。

import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { EmbeddingService } from './embedding.service';

export interface SearchResult {
  chunkId: string;
  documentId: string;
  content: string;
  score: number;
  chunkIndex: number;
}

@Injectable()
export class SearchService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly embedding: EmbeddingService,
  ) {}

  async similaritySearch(
    query: string,
    userId: string,
    topK = 5,
  ): Promise<SearchResult[]> {
    const [vector] = await this.embedding.embedTexts([query]);
    if (!vector || vector.length === 0) {
      throw new Error('EmbeddingService returned no vector for query');
    }
    // vectorLiteral must be injected as a SQL literal (not a bind parameter) because
    // PostgreSQL cannot cast a text bind parameter to vector via $1::vector in prepared statements.
    // Prisma.raw() inlines it as SQL text. userId and topK remain parameterized (safe).
    // The vector values come from the model's float output — no user input reaches this string.
    const vectorLiteral = `[${vector.join(',')}]`;
    const vecRaw = Prisma.raw(`'${vectorLiteral}'::vector`);

    const rows = await this.prisma.$queryRaw<
      Array<{
        chunk_id: string;
        document_id: string;
        content: string;
        score: string | number;
        chunk_index: number;
      }>
    >`
      SELECT
        dc.id             AS chunk_id,
        dc."documentId"   AS document_id,
        dc.content        AS content,
        dc."chunkIndex"   AS chunk_index,
        1 - (dc.embedding <=> ${vecRaw}) AS score
      FROM document_chunks dc
      JOIN documents d ON d.id = dc."documentId"
      WHERE d."userId" = ${userId}
        AND dc.embedding IS NOT NULL
      ORDER BY dc.embedding <=> ${vecRaw}
      LIMIT ${topK}
    `;

    return rows.map((r) => ({
      chunkId: r.chunk_id,
      documentId: r.document_id,
      content: r.content,
      score: Number(r.score),
      chunkIndex: r.chunk_index,
    }));
  }
}

5.7.3 完整的文档处理 Pipeline

现在把 5.5(上传)、5.6(解析+分块)、5.7(向量化)串成一条完整的 Pipeline:

// 更新 POST /api/documents/:id/process 的实现
async processDocument(documentId: string, userId: string) {
  // 权限校验
  await this.documentService.findById(documentId, userId);

  // Step 1: 解析 + 分块
  const { chunkCount } = await this.chunkService.chunkDocument(documentId);

  // Step 2: 向量化
  const { embedded } = await this.embeddingService.embedChunks(documentId);

  return {
    documentId,
    chunkCount,
    embedded,
    status: 'completed',
  };
}

🧪 验证步骤(对应 5.7)

# 触发完整处理流程(解析 + 分块 + 向量化)
curl -X POST http://localhost:3001/api/documents/$DOC_ID/process \
  -H "Authorization: Bearer $TOKEN"

# 语义检索
curl -X POST http://localhost:3001/api/search \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "蓝牙耳机未拆封能退货吗", "topK": 3}'

验收标准:处理完成后,DocumentChunk 表中的 embedding 字段不再为 NULL;语义检索返回与查询语义相关的文档片段,且只包含当前用户的文档。

image 6.png

image 7.png


5.8 异步任务通知:SSE 推送机制

5.7 完成后,整条“上传 → 分块 → 向量化 → 落库”的 Pipeline 已经跑通了。但这里还有一个关键问题:向量化耗时不可控,难以预估。 上传可以同步感知,但文档量一大任务会迅速堆积,因此向量化一般需要异步执行:POST /api/documents/:id/process 接口会立即返回 202 Accepted,实际处理在后台进行,前端也就无法得知向量化何时完成或是否失败。

这就需要一个实时通知机制,让后端在任务状态变化时主动推送给前端。

既然已经在借助 AI 来开发项目,我们不必过度关注真实项目的复杂度。有时候为了实现简单,后端会让前端直接轮询;也可能出于服务器资源等原因,选择轮询方案。但对我们的项目而言,只要有更好的方案,就直接采用。先不要过多考虑成本问题,因为我们的目标是学习一整套完整架构。

5.8.1 为什么选 SSE 而不是 WebSocket

维度 SSE WebSocket
通信方向 服务端 → 客户端(单向) 双向
协议 标准 HTTP,自动重连 独立协议,需手动重连
复杂度 低,NestJS 原生支持 @Sse() 高,需额外网关配置
适用场景 任务进度、状态通知 聊天、实时协作

我们的场景是“后端告知前端任务完成了”,典型的单向推送,SSE 完全够用。

5.8.2 整体流程

sequenceDiagram
    participant FE as 前端
    participant API as NestJS API
    participant SSE as SseService
    participant Worker as ChunkService
    participant DB as PostgreSQL

    FE->>API: GET /api/sse(建立 SSE 连接)
    API->>SSE: subscribe(userId)
    FE->>API: POST /api/documents/:id/process
    API-->>FE: 202 Accepted
    API->>Worker: processDocument(documentId, userId)
    Worker->>SSE: emit(userId, { status: 'processing' })
    SSE-->>FE: event: { taskType: 'document_vectorize', status: 'processing' }
    Worker->>DB: 分块 + 向量化 + 落库
    Worker->>SSE: emit(userId, { status: 'done', metadata: { chunkCount } })
    SSE-->>FE: event: { taskType: 'document_vectorize', status: 'done' }

mermaid-diagram-2026-04-12-173633.png

5.8.3 SseService:基于内存 Subject 的推送中心

// services/chat/src/sse/sse.service.ts
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
import { v4 as uuid } from 'uuid';

export interface TaskEvent {
  id: string;
  taskType: string;
  taskId: string;
  status: 'processing' | 'done' | 'error';
  message: string;
  metadata?: Record<string, unknown>;
  createdAt: string;
}

@Injectable()
export class SseService {
  private clients = new Map<string, Subject<TaskEvent>>();

  subscribe(userId: string): Observable<TaskEvent> {
    if (!this.clients.has(userId)) {
      this.clients.set(userId, new Subject<TaskEvent>());
    }
    return this.clients.get(userId)!.asObservable();
  }

  emit(userId: string, event: Omit<TaskEvent, 'id' | 'createdAt'>) {
    const subject = this.clients.get(userId);
    if (subject) {
      subject.next({
        ...event,
        id: uuid(),
        createdAt: new Date().toISOString(),
      });
    }
  }

  remove(userId: string) {
    const subject = this.clients.get(userId);
    if (subject) {
      subject.complete();
      this.clients.delete(userId);
    }
  }
}

设计要点:

5.8.4 SseController:SSE 端点

// services/chat/src/sse/sse.controller.ts
import { Controller, Sse, Req, UseGuards } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SseService } from './sse.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Controller('api')
export class SseController {
  constructor(private sseService: SseService) {}

  @Sse('sse')
  @UseGuards(JwtAuthGuard)
  sse(@Req() req: any): Observable<MessageEvent> {
    const userId = req.user.userId;

    // 连接断开时清理
    req.on('close', () => this.sseService.remove(userId));

    return this.sseService.subscribe(userId).pipe(
      map((event) => ({
        data: JSON.stringify(event),
      } as MessageEvent)),
    );
  }
}

5.8.5 改造 ChunkService:接入 SSE 推送

在已有的 processDocument 方法中,在关键节点插入 SSE 推送:

// 在 ChunkService 中注入 SseService
constructor(
  private prisma: PrismaService,
  private embeddingService: EmbeddingService,
  private sseService: SseService,  // 新增
) {}

async processDocument(documentId: string, userId: string) {
  // 推送“开始处理”
  this.sseService.emit(userId, {
    taskType: 'document_vectorize',
    taskId: documentId,
    status: 'processing',
    message: '文档正在解析和向量化...',
  });

  try {
    // ... 原有的解析、分块、向量化逻辑 ...

    // 推送“处理完成”
    this.sseService.emit(userId, {
      taskType: 'document_vectorize',
      taskId: documentId,
      status: 'done',
      message: `向量化完成,共 ${chunkCount} 个分块`,
      metadata: { chunkCount },
    });
  } catch (error) {
    // 推送“处理失败”
    this.sseService.emit(userId, {
      taskType: 'document_vectorize',
      taskId: documentId,
      status: 'error',
      message: error.message ?? '向量化失败',
    });
  }
}

5.8.6 TaskEvent 的设计意图

TaskEvent 是一个通用的任务事件结构,不仅用于文档向量化。后续任何异步任务(比如批量导入、报告生成)都可以复用同一套机制,只需设置不同的 taskType

字段 用途 示例
taskType 区分任务类型,前端据此分发处理 document_vectorize
taskId 关联到具体业务对象 documentId
status 任务当前状态 processing / done / error
metadata 携带额外信息,如结果数据 { chunkCount: 12 }

🧪 验证步骤(对应 5.8)

# 终端 1:建立 SSE 连接(保持打开)
curl -N -H "Authorization: Bearer $TOKEN" \
  http://localhost:3001/api/sse

# 终端 2:触发文档处理
curl -X POST http://localhost:3001/api/documents/$DOC_ID/process \
  -H "Authorization: Bearer $TOKEN"

验收标准:终端 1 先收到 status: processing,等待数秒后收到 status: done(含 chunkCount);如果处理失败则收到 status: error

image 8.png


5.9 完整调用链路:从登录到智能分析

到这里,所有独立的能力都已经落地了。最后一步,是把它们串成一条端到端的调用链路。

5.9.1 调用链路全貌

sequenceDiagram
    participant U as 用户
    participant Auth as JWT Guard
    participant Chat as ChatController
    participant History as DatabaseChatMessageHistory
    participant Search as SearchService
    participant Agent as OrchestratorService
    participant DB as PostgreSQL

    U->>Auth: POST /conversations/:id/chat(Bearer Token)
    Auth->>Chat: userId + conversationId + input
    Chat->>History: 读取会话历史
    History->>DB: SELECT messages
    DB-->>History: 历史消息
    Chat->>Search: 语义检索(query=input, userId)
    Search->>DB: pgvector 余弦距离查询
    DB-->>Search: 相关文档片段
    Chat->>Agent: 历史 + 检索上下文 + 当前输入
    Agent->>Agent: Multi-Agent 分析(抽取→校验→风控→QA→汇总)
    Agent-->>Chat: 分析报告
    Chat->>DB: 写入 human message + ai message
    Chat-->>U: 返回报告 + 引用文档

mermaid-diagram-2026-04-12-173718.png

5.9.2 统一分析服务(整合版)

// services/api/src/llm/advanced-analysis.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DatabaseChatMessageHistory } from '../conversation/db-chat-history';
import { SearchService } from '../embedding/search.service';
import { OrchestratorService } from './agents/orchestrator.service';

@Injectable()
export class AdvancedAnalysisService {
  constructor(
    private prisma: PrismaService,
    private searchService: SearchService,
    private orchestrator: OrchestratorService,
  ) {}

  async analyze(userId: string, conversationId: string, input: string) {
    // 1. 读取会话历史
    const history = new DatabaseChatMessageHistory(
      this.prisma,
      conversationId,
    );
    const messages = await history.getMessages();

    // 2. 语义检索用户文档
    const retrievedDocs = await this.searchService.similaritySearch(
      input,
      userId,
      3,
    );

    // 3. 组装完整上下文
    const contextParts = [
      messages.length
        ? `历史对话:\n${messages.map((m) => `${m._getType()}: ${m.content}`).join('\n')}`
        : '',
      retrievedDocs.length
        ? `相关文档:\n${retrievedDocs.map((d) => `[${d.originalName}] ${d.content}`).join('\n---\n')}`
        : '',
      `当前输入:${input}`,
    ]
      .filter(Boolean)
      .join('\n\n');

    // 4. 调用 Multi-Agent 分析
    const result = await this.orchestrator.orchestrate(contextParts);

    // 5. 写入消息历史
    await history.addMessage(new (await import('@langchain/core/messages')).HumanMessage(input));
    await history.addMessage(
      new (await import('@langchain/core/messages')).AIMessage(
        result.report ?? '分析未完成',
      ),
    );

    // 6. 返回完整结果
    return {
      ...result,
      retrievedDocuments: retrievedDocs.map((d) => ({
        documentName: d.originalName,
        content: d.content.slice(0, 200) + '...',
        score: d.score,
      })),
    };
  }
}

5.9.3 与第四章的关键差异对照

环节 第四章(Mock) 第五章(生产)
会话历史 memory.getHistory(sessionId)
内存 Map 存储 DatabaseChatMessageHistory
PostgreSQL 持久化
文档检索 手动灌库到 MemoryVectorStore 用户上传 → 自动向量化 → pgvector 语义检索
用户隔离 无,全局共享 JWT 鉴权 + userId 过滤
结果持久化 writeFile('tickets/...')
本地文件 Message 表 + 关联会话和用户
重启后状态 全部丢失 完整保留

🧪 验证步骤(对应 5.9 · 完整链路)

# Step 1: 登录获取 Token
curl -X POST http://localhost:3001/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@test.com","password":"123456"}'

# Step 2: 上传退货政策文档
curl -X POST http://localhost:3001/api/documents/upload \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@workspace/policies/return-policy.md"

# Step 3: 处理文档(解析 + 分块 + 向量化)
curl -X POST http://localhost:3001/api/documents/$DOC_ID/process \
  -H "Authorization: Bearer $TOKEN"

# Step 4: 创建会话
curl -X POST http://localhost:3001/api/conversations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "退货咨询"}'

# Step 5: 四轮对话
curl -X POST http://localhost:3001/api/conversations/$CONV_ID/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input": "我买的蓝牙耳机降噪效果不好,想退货"}'

curl -X POST http://localhost:3001/api/conversations/$CONV_ID/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input": "订单号是 EC20240315001"}'

curl -X POST http://localhost:3001/api/conversations/$CONV_ID/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input": "我是昨天收到的,还没拆封"}'

curl -X POST http://localhost:3001/api/conversations/$CONV_ID/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input": "帮我判断一下能不能退,如果可以请告诉我下一步操作"}'

# Step 6: 重启服务后验证历史保留
bun run dev:api
curl http://localhost:3001/api/conversations/$CONV_ID/messages \
  -H "Authorization: Bearer $TOKEN"

最终验收标准:

image 9.png

image 10.png


5.10 本章小结

这一章围绕同一个核心目标——从 Mock 到生产——把第四章的每一项能力都做了一次"落地升级":

本章的核心收获

写在最后🧪

这里是言萧凡的 AI 编程实验室。 我会在这里持续记录和分享 AI 工具、编程实践,以及那些值得沉淀下来的高效工作方法。 不只聊概念,也尽量分享能直接上手、能够复用的经验。 希望这间小小的实验室,能陪你一起探索、实践和成长。2026 年,一起进步。

有兴趣的话可以添加我的微信号一起交流,不仅是编程也可以是畅谈人生。