大家好,最近在项目中落地了一个基于 SpringBoot 3.0 和 PostgreSQL 的智能客服系统,效果还不错,响应速度和准确率都有显著提升。今天就来和大家分享一下整个架构的设计思路、核心实现以及踩过的一些坑,希望能给有类似需求的同学一些参考。

传统客服系统,尤其是规则匹配或简单关键词匹配的版本,通常面临几个核心痛点:一是响应延迟高,用户问题稍微复杂点,后台匹配规则库就得遍历一遍,高峰期体验很差;二是意图识别不准,稍微换个问法可能就匹配不上,导致答非所问;三是扩展困难,每增加一个业务知识点,就要手动配置一堆规则,维护成本极高。

为了解决这些问题,我们引入了 AI 能力,核心思路是:将客服知识库转化为向量(Embedding)存入 PostgreSQL,利用其 pgvector 扩展进行高效的相似度搜索,快速找到最相关的知识片段,再通过大语言模型(LLM)生成友好、准确的回复。下面我就分几个部分来详细拆解。

https://i-operation.csdnimg.cn/images/506657cbf1a449dba4bd12ff99f00c22.jpeg

1. 技术选型:为什么是 PostgreSQL + pgvector?

在向量数据库的选择上,我们对比了专用的向量数据库(如 Milvus, Pinecone)和 PostgreSQL 的 pgvector 扩展。最终选择后者,主要基于以下几点考虑:

  1. 技术栈统一与运维简化:团队已经熟练使用 PostgreSQL,引入 pgvector 无需额外维护一套新的数据库系统,降低了运维复杂度和成本。事务、备份、监控等都可以沿用现有体系。
  2. 数据一致性保障:客服系统的知识库(向量数据)和用户对话记录、业务元数据(如订单号、用户ID)是强关联的。使用 PostgreSQL 可以保证在同一个事务内完成向量插入和相关业务数据的更新,避免了分布式事务的难题。
  3. 成熟的生态与性能:pgvector 支持多种索引(如 IVFFlat, HNSW),对于千万级以下的向量数据,其查询性能已经足够优秀,能够满足我们毫秒级响应的要求。并且它与 Spring Data JPA 等框架集成起来非常顺畅。
  4. 成本考量:专用向量数据库通常有额外的云服务费用或更高的自建成本。pgvector 作为扩展,几乎是零额外成本。

当然,如果数据量特别巨大(十亿级以上)或对查询延迟有极致的追求(亚毫秒级),专用向量数据库可能更有优势。但对于大多数中小型智能客服场景,PostgreSQL + pgvector 是一个性价比和工程效率极高的选择。

2. 核心架构与实现步骤

整个系统的流程可以概括为:用户提问 -> 文本转向量 -> 向量相似度搜索 -> 获取相关上下文 -> 构造 Prompt 调用 LLM -> 返回生成结果。下面我们看看关键环节的实现。

2.1 SpringBoot 3.0 的响应式编程改造

为了应对高并发查询,我们利用 SpringBoot 3.0 对响应式编程的良好支持,对部分 IO 密集型操作进行了改造。主要是将调用 Embedding 模型 API 和 LLM API 的环节异步化。

首先,在 pom.xml 中引入 WebFlux 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

然后,配置一个带连接池的、非阻塞的 HTTP 客户端,用于调用外部 AI 服务:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {
    @Bean
    public WebClient aiServiceWebClient() {
        return WebClient.builder()
                .baseUrl("https://your-ai-service.com")
                .build();
    }
}
2.2 PostgreSQL 向量存储与索引优化

首先,确保你的 PostgreSQL 安装了 pgvector 扩展。然后,我们设计一个知识库表:

-- 启用扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 创建知识表,假设我们使用 768 维的向量
CREATE TABLE faq_knowledge (
    id BIGSERIAL PRIMARY KEY,
    question TEXT NOT NULL,
    answer TEXT NOT NULL,
    -- 存储由问题文本生成的向量
    embedding vector(768),
    category VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 为 embedding 列创建 HNSW 索引,以加速相似度搜索
-- 注意:创建索引前需要先有数据,或者使用 `WITH (lists=100)` 等参数调优
CREATE INDEX ON faq_knowledge USING hnsw (embedding vector_cosine_ops);

这里选择了 HNSW (Hierarchical Navigable Small World) 索引,它在查询精度和速度之间取得了很好的平衡。vector_cosine_ops 表示使用余弦相似度作为距离度量,这对文本相似度搜索很有效。

在 Spring Boot 应用中,我们使用 JPA 来操作这个实体。需要定义一个自定义类型来映射 vector

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

@Data
@Entity
@Table(name = "faq_knowledge")
public class FaqKnowledge {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String question;
    private String answer;
    private String category;

    // 使用 Hibernate 的用户自定义类型或 JSON 类型来存储向量数组
    // 这里简化处理,实际存储时可能需要序列化为文本或使用 pgvector 提供的类型支持
    // 一种常见做法是使用 double[] 并通过 Converter 处理
    @Column(columnDefinition = "vector(768)")
    private String embedding; // 实际可能是 float[],这里用String示意存储格式

    // ... getters and setters
}

更实际的向量操作,我们可能会在 Repository 层使用原生 SQL 查询。

2.3 核心服务层:异步处理与 AI 集成

核心的服务类负责串联整个流程。我们创建一个 AsyncAICustomerService

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.List;

@Service
@Slf4j
@RequiredArgsConstructor
public class AsyncAICustomerService {
    private final FaqKnowledgeRepository knowledgeRepo; // 假设的JPA仓库
    private final WebClient aiServiceWebClient;

    public Mono<String> getAnswerAsync(String userQuestion) {
        // 1. 异步调用 Embedding 服务,将用户问题转为向量
        Mono<float[]> questionEmbeddingMono = aiServiceWebClient.post()
                .uri("/embeddings")
                .bodyValue(new EmbeddingRequest(userQuestion))
                .retrieve()
                .bodyToMono(EmbeddingResponse.class)
                .map(EmbeddingResponse::getEmbedding)
                .onErrorResume(e -> {
                    log.error("Failed to get embedding", e);
                    return Mono.empty();
                });

        // 2. 向量查询与LLM生成串联
        return questionEmbeddingMono.flatMap(embedding -> {
            // 2.1 执行向量相似度搜索,获取最相关的几条知识
            // 这里使用Repository执行原生SQL查询
            List<FaqKnowledge> relatedKnowledges = knowledgeRepo.findSimilarKnowledge(embedding, 5);

            if (relatedKnowledges.isEmpty()) {
                return Mono.just("抱歉,我暂时无法回答这个问题。");
            }

            // 2.2 构建Prompt,包含相关上下文和用户问题
            String context = buildContextFromKnowledges(relatedKnowledges);
            String prompt = String.format("基于以下信息:\n%s\n\n请回答用户的问题:%s", context, userQuestion);

            // 2.3 异步调用 LLM 服务生成最终回答
            return aiServiceWebClient.post()
                    .uri("/chat/completions")
                    .bodyValue(new ChatRequest(prompt))
                    .retrieve()
                    .bodyToMono(ChatResponse.class)
                    .map(ChatResponse::getAnswer)
                    .onErrorReturn("服务繁忙,请稍后再试。");
        });
    }

    private String buildContextFromKnowledges(List<FaqKnowledge> knowledges) {
        StringBuilder sb = new StringBuilder();
        for (FaqKnowledge knowledge : knowledges) {
            sb.append("Q: ").append(knowledge.getQuestion()).append("\n");
            sb.append("A: ").append(knowledge.getAnswer()).append("\n\n");
        }
        return sb.toString();
    }
}

对应的 FaqKnowledgeRepository 中,需要定义向量相似度搜索的方法。这里使用 @Query 注解执行原生 SQL:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;

public interface FaqKnowledgeRepository extends JpaRepository<FaqKnowledge, Long> {
    // 使用余弦相似度搜索,:embedding 需要被转换为 PostgreSQL vector 字面量格式
    @Query(value = "SELECT * FROM faq_knowledge ORDER BY embedding <=> CAST(:embedding AS vector) LIMIT :limit", nativeQuery = true)
    List<FaqKnowledge> findSimilarKnowledge(@Param("embedding") String embeddingStr, @Param("limit") int limit);
}

注意:上面的 CAST(:embedding AS vector) 需要你将 float 数组转换为 PostgreSQL 能识别的 vector 字面量字符串,如 [0.1, 0.2, ...]。这通常需要在服务层进行格式转换。

2.4 控制器层:暴露异步接口

最后,我们提供一个异步的 REST 接口:

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/ai-chat")
@RequiredArgsConstructor
public class AIChatController {
    private final AsyncAICustomerService customerService;

    @PostMapping
    public Mono<ApiResponse<String>> chat(@RequestBody ChatRequest request) {
        return customerService.getAnswerAsync(request.getQuestion())
                .map(answer -> ApiResponse.success(answer))
                .onErrorReturn(ApiResponse.error("系统内部错误"));
    }
}

https://i-operation.csdnimg.cn/images/e3a29ce907f64f81a618e4be149f4c1f.jpeg

3. 性能测试与优化

系统上线前,我们进行了压测,主要关注两个指标:QPS(每秒查询率)查询延迟

  1. 不同并发量下的 QPS 对比:我们对比了纯规则匹配的老系统和新的 AI 系统。在相同 4 核 8G 的服务器上,模拟了从 50 到 500 的并发用户。老系统在并发 200 时 QPS 开始急剧下降,响应时间超过 5 秒。而新系统(SpringBoot 响应式 + pgvector HNSW索引)在并发 500 时,QPS 仍能稳定在 120 左右,平均响应时间在 800ms 以内,吞吐量提升了约 300%。瓶颈主要出现在外部 LLM API 的调用延迟上。
  2. 向量维度对查询延迟的影响:我们测试了 384 维和 768 维两种模型。在 100 万条知识库数据下,768 维向量的查询延迟比 384 维平均高出约 15%。但 768 维模型在意图识别准确率上高出约 8%。因此,我们最终选择了 768 维,并通过优化索引参数(如 HNSW 的 mef_construction)来弥补延迟损失。

4. 生产环境避坑指南

实际部署后,我们遇到了几个典型问题,这里分享给大家:

  1. 连接泄露的预防措施:响应式编程虽然高效,但如果 Mono/Flux 链没有正确订阅或发生异常,可能导致底层数据库连接或 HTTP 连接未释放。我们通过以下方式预防:

    • 使用 WebClient 时,确保所有响应都被消费(如使用 bodyToMono)。
    • 为 R2DBC(如果使用)或 HTTP 客户端配置连接池和超时时间。
    • 使用 @Transactional 注解时,注意响应式与非阻塞的兼容性,必要时使用响应式事务管理器。
    • 增加监控,对连接池使用率设置告警。
  2. 大语言模型的限流策略:外部 LLM API 通常有 RPM(每分钟请求数)和 TPM(每分钟令牌数)限制。我们必须实现客户端限流:

    • 令牌桶算法:使用 Resilience4j 或 Guava 的 RateLimiter 对调用 LLM 的请求进行限流,防止突发流量击穿上游服务。
    • 队列与降级:当请求超过阈值时,将任务放入队列异步处理,或直接返回一个兜底的、基于向量搜索的简洁答案(不经过LLM润色),实现优雅降级。
    • 缓存:对常见、标准问题的最终生成答案进行缓存,可以设置一个较短的 TTL,既能减少重复计算,又能应对瞬时高峰。
  3. 对话上下文的存储优化:智能客服需要支持多轮对话。我们最初将整个对话历史(可能很长)每次都传给 LLM,导致 Token 消耗巨大、速度慢。

    • 摘要化存储:不再存储完整的对话历史,而是为每个会话维护一个“对话摘要”。每次新交互后,用 LLM 将当前对话的核心信息(如已确认的用户需求、已解决的问题)总结成一段简短的文本,作为下一轮对话的上下文。这大大减少了传输的数据量。
    • 向量化检索:将历史对话中的关键 Q&A 对也存入向量库。在新一轮对话中,不仅检索静态知识库,也检索该用户的历史相关对话,使得回复更具连贯性。

5. 总结与思考

通过 SpringBoot 3.0 的响应式特性、PostgreSQL 强大的 pgvector 扩展以及外部 LLM 能力的结合,我们构建了一个高性能、易扩展的智能客服系统。它成功地将平均响应时间从秒级降低到毫秒级,并大幅提升了意图识别的准确率。

最后,抛出一个开放性问题供大家探讨:如何平衡模型精度与响应速度?

在我们的实践中,这是一个持续的权衡。使用更大的 Embedding 模型和 LLM 固然能提升精度,但会直接增加向量维度(影响搜索速度)和生成延迟。我们的策略是:

  • 分级处理:对于简单、高频问题,优先使用向量搜索匹配的答案,甚至可以直接返回,跳过 LLM 生成,追求极速。
  • 模型蒸馏:考虑使用蒸馏后的小模型来处理部分场景。
  • 异步生成:对于复杂问题,可以先快速返回一个“正在思考”的提示,然后在后台异步调用更强大的模型生成详细答案,再通过 WebSocket 或轮询推送给用户。

技术方案没有银弹,最好的选择永远是贴合自己的业务场景和资源约束。希望这篇分享能给大家带来一些启发。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐