最近在做一个智能客服项目,从零到一踩了不少坑。智能客服听起来高大上,但真做起来,你会发现它本质上是一个“高并发+复杂状态+AI能力”的综合体。今天我就结合SpringAI这个新晋利器,把整个实战过程,从架构设计到生产避坑,掰开揉碎了跟大家聊聊。

智能客服系统架构示意图

1. 背景痛点:智能客服的“三座大山”

在动手之前,我们先得搞清楚要解决什么问题。传统客服系统或者简单的问答机器人,在真实企业级场景下,往往会遇到几个硬骨头:

  1. 高并发下的会话管理:想象一下双十一大促,成千上万的用户同时涌入。每个用户的对话都是一个独立的会话(Session),里面包含了历史消息、用户信息、当前状态等。如何在海量并发下,快速、准确地为每个请求找到并维护其对应的会话上下文,是个大挑战。内存存储扛不住,数据库直接查又太慢。

  2. 意图识别的准确率:用户不会按你设定的模板说话。“我要改手机号”、“怎么换绑手机”、“手机号能改吗”可能都是一个意图。传统的基于关键词或简单规则匹配的引擎(比如早期的AIML),准确率感人,泛化能力差。而直接调用大模型API,成本高、延迟大,且难以控制输出格式。

  3. 多轮对话的上下文保持:真正的客服对话很少一轮结束。用户可能先问“流量套餐”,接着问“哪个划算”,最后再问“怎么办理”。AI必须能记住前面几轮对话的内容,才能给出连贯、准确的回答。这个“记忆”如何设计,既不能太长(模型有Token限制),也不能丢失关键信息。

2. 技术选型:为什么是SpringAI?

面对这些痛点,市面上方案不少,我们来快速对比一下:

  • 传统规则引擎:代表如Drools。优点是完全可控,性能极高。缺点是维护成本爆炸,每加一个新意图或说法,就要写一堆规则,无法应对自然语言的多样性。适合流程固定、说法有限的场景(如银行密码重置流程),不适合开放域客服。

  • Rasa等开源框架:功能强大,NLU(自然语言理解)和对话管理(Dialogue Management)模块分离清晰。但它是Python生态,对于以Java/SpringBoot为主技术栈的团队,引入它意味着要维护一套Python服务,增加运维复杂度,且与现有Java系统整合有桥梁成本。

  • 直接调用大模型API:比如OpenAI、文心一言的API。开发最快,效果通常也最好。但问题也很明显:成本(按Token收费,日活高的话账单吓人)、延迟(网络请求+模型推理)、数据隐私、以及输出不可控(可能胡言乱语,需要大量Prompt工程和后处理)。

  • SpringAI:这是Spring官方推出的AI应用开发框架。它的核心价值在于 “将AI能力无缝集成到Spring生态中” 。你可以像使用JdbcTemplate操作数据库一样,使用AiClient来操作AI模型(无论是OpenAI、Azure OpenAI还是本地部署的Ollama)。它提供了统一的接口,简化了对话模板、提示词工程、函数调用等常见模式。

我们选择SpringAI的依据

  1. 技术栈统一:团队熟悉SpringBoot,学习成本低,可以复用现有的安全、监控、事务管理等基础设施。
  2. 灵活性:它不绑定特定模型供应商。今天可以用OpenAI,明天业务要求数据不出境,可以平滑切换到本地部署的ChatGLM或通义千问,代码改动极小。
  3. Spring生态集成:天然支持@Retryable重试、@CircuitBreaker熔断、@Cacheable缓存等,这对于构建高可用的生产级AI服务至关重要。
  4. 平衡点:它允许我们结合使用本地轻量模型(处理简单、高频的意图分类)和远程大模型(处理复杂、开放的问答),在成本、性能和效果间取得平衡。

3. 核心实现:用SpringAI搭建对话引擎

3.1 对话管理模块与状态维护

智能客服的核心是一个状态机。我们定义一个ConversationSession对象来封装会话状态。

// 会话状态实体
@Data
public class ConversationSession {
    private String sessionId; // 会话唯一ID,通常由前端生成或根据用户ID创建
    private String userId;
    private List<Message> history; // 对话历史记录
    private Map<String, Object> context; // 对话上下文,存放提取的槽位(Slots)信息,如 {“phoneNumber”: “13800138000”}
    private String currentState; // 当前对话状态,如 “GREETING”, “COLLECTING_PHONE”, “CONFIRMING_ORDER”
    private LocalDateTime lastActiveTime;
}

// 消息体
@Data
public class Message {
    private String role; // “user”, “assistant”, “system”
    private String content;
    private LocalDateTime timestamp;
}

会话的存储我们放在Redis里,Key设计为 chat:session:{sessionId},并设置合理的TTL(如30分钟无活动后过期)。SpringAI的ChatClient本身不管理历史,我们需要自己维护一个List<Message>,并在每次请求时传入。

@Service
public class ChatService {
    @Autowired
    private ChatClient chatClient; // SpringAI自动注入的ChatClient
    @Autowired
    private RedisTemplate<String, ConversationSession> redisTemplate;

    public ChatResponse handleMessage(String sessionId, String userInput) {
        // 1. 从Redis恢复或创建会话
        ConversationSession session = redisTemplate.opsForValue().get("chat:session:" + sessionId);
        if (session == null) {
            session = new ConversationSession(sessionId);
        }

        // 2. 将用户输入加入历史
        session.getHistory().add(new Message("user", userInput, LocalDateTime.now()));

        // 3. 构建Prompt。这里我们传入系统指令和完整历史。
        // 系统指令用于设定AI角色和行为规范,是控制输出的关键。
        String systemPrompt = “你是一个专业的电信客服助手,请用友好、简洁的语言回答问题。如果用户想办理业务,请引导他提供必要信息。”;
        Prompt prompt = new Prompt(new SystemPrompt(systemPrompt), new UserPrompt(userInput));
        // 更复杂的做法:将session.getHistory()中的所有消息都构建成PromptMessage加入

        // 4. 调用AI模型获取回复
        ChatResponse response = chatClient.call(prompt); // 这里会调用配置的AI模型(如OpenAI)

        // 5. 将AI回复加入历史,并更新会话状态(这里简化了,实际可能需要根据回复内容解析并更新currentState和context)
        String aiReply = response.getResult().getOutput().getContent();
        session.getHistory().add(new Message("assistant", aiReply, LocalDateTime.now()));
        session.setLastActiveTime(LocalDateTime.now());

        // 6. 持久化更新后的会话回Redis
        redisTemplate.opsForValue().set("chat:session:" + sessionId, session, 30, TimeUnit.MINUTES);

        return response;
    }
}

3.2 意图识别集成与容错处理

完全依赖大模型做意图识别成本高。我们的策略是:本地轻量模型做第一层粗分类,复杂情况再fallback到大模型

  1. 本地模型:使用SpringAIEmbeddingClient + 向量数据库(如Redis Vector Search)实现。预先将常见问题(Q)转化为向量存入。用户提问时,将其向量化,进行相似度搜索,找到最匹配的预设Q,其对应的A就是答案。这适用于标准问答。

  2. 大模型兜底:对于本地模型匹配度低(相似度分数低于阈值)的查询,或者明确需要多轮对话、复杂推理的查询,再调用大模型。

关键点:容错机制。网络调用AI服务不稳定,必须加入重试和熔断。

@Service
public class IntentService {
    @Autowired
    private ChatClient chatClient; // 用于复杂意图识别
    @Autowired
    private EmbeddingClient embeddingClient; // 用于向量化
    // 假设有一个VectorRepository用于向量检索

    // 使用@Retryable,当发生特定异常(如网络超时)时重试3次
    @Retryable(value = {ResourceAccessException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000))
    // 使用@CircuitBreaker,失败率达到阈值后熔断,快速失败,防止雪崩
    @CircuitBreaker(name = "aiIntentCB", fallbackMethod = "fallbackRecognizeIntent")
    public IntentResult recognizeIntent(String query) {
        // 1. 先用本地向量库快速匹配
        double[] queryVector = embeddingClient.embed(query);
        List<MatchResult> localMatches = vectorRepository.findSimilar(queryVector, 0.8); // 相似度阈值0.8

        if (!localMatches.isEmpty()) {
            return new IntentResult(“STANDARD_QA”, localMatches.get(0).getAnswer());
        }

        // 2. 本地匹配不上,调用大模型进行意图分类和槽位提取
        // 通过精心设计的Prompt,让大模型以固定JSON格式返回,便于解析
        String promptText = “””
            请分析用户查询的意图并提取关键信息。
            用户查询:“%s”
            请严格按照以下JSON格式回复:
            {“intent”: “办理业务|查询账单|投诉建议|其他”, “slots”: {“业务类型”: “”, “手机号”: “”}}
            “””.formatted(query);

        Prompt prompt = new Prompt(new UserPrompt(promptText));
        ChatResponse response = chatClient.call(prompt);
        String jsonOutput = response.getResult().getOutput().getContent();

        // 解析jsonOutput,返回IntentResult
        return parseJsonToIntentResult(jsonOutput);
    }

    // 熔断后的降级方法,返回一个默认意图或引导用户重试
    public IntentResult fallbackRecognizeIntent(String query, Exception e) {
        log.warn(“意图识别服务降级,查询:{}”, query, e);
        return new IntentResult(“FALLBACK”, “服务暂时繁忙,请稍后再试或直接描述您的问题。”);
    }
}

4. 性能优化:应对高并发实战

4.1 Redis会话存储分片策略

当用户量巨大时,所有会话存在单个Redis实例或集群的同一个逻辑库中,会有性能瓶颈和单点风险。我们采用基于sessionId的分片策略

// 配置多个Redis连接,指向不同的实例或集群
@Configuration
public class RedisShardingConfig {
    @Bean(name = “redisTemplateShard1”)
    public RedisTemplate<String, Object> redisTemplateShard1(…) { … }

    @Bean(name = “redisTemplateShard2”)
    public RedisTemplate<String, Object> redisTemplateShard2(…) { … }
}

@Service
public class ShardedSessionService {
    // 注入多个RedisTemplate
    @Autowired
    @Qualifier(“redisTemplateShard1”)
    private RedisTemplate<String, ConversationSession> redisTemplate1;

    @Autowired
    @Qualifier(“redisTemplateShard2”)
    private RedisTemplate<String, ConversationSession> redisTemplate2;

    // 根据sessionId的哈希值决定存到哪个分片
    private RedisTemplate<String, ConversationSession> getTargetRedisTemplate(String sessionId) {
        int hash = Math.abs(sessionId.hashCode());
        return (hash % 2 == 0) ? redisTemplate1 : redisTemplate2;
    }

    public void saveSession(ConversationSession session) {
        RedisTemplate<String, ConversationSession> template = getTargetRedisTemplate(session.getSessionId());
        template.opsForValue().set(“chat:session:” + session.getSessionId(), session, 30, TimeUnit.MINUTES);
    }
}

更复杂的场景可以使用一致性哈希算法,减少节点增减时的数据迁移量。

4.2 负载测试发现的线程阻塞问题

在压测时,我们发现当AI服务(如OpenAI API)响应变慢时,整个客服接口的线程池很快被占满,导致服务不可用。这是因为ChatClient.call()是同步阻塞调用。

解决方案:异步化与超时控制。

  1. 使用@Async + CompletableFuture:将耗时的AI调用放入线程池执行,立即返回Future,释放Web容器线程(如Tomcat线程)。
@Service
public class AsyncChatService {
    @Async(“aiTaskExecutor”) // 指定一个专用于AI任务的线程池
    public CompletableFuture<ChatResponse> callAiAsync(Prompt prompt) {
        return CompletableFuture.completedFuture(chatClient.call(prompt));
    }
}

// 在Controller或主服务中
public ChatResponse handleMessageAsync(String sessionId, String input) throws Exception {
    // … 准备prompt …
    CompletableFuture<ChatResponse> future = asyncChatService.callAiAsync(prompt);
    // 可以设置超时时间,避免长时间等待
    return future.get(5, TimeUnit.SECONDS);
}
  1. 配置专属线程池:在ThreadPoolTaskExecutor中合理设置核心线程数、最大线程数和队列容量,避免资源耗尽。

  2. ChatClient层面配置超时:通过RestClient自定义配置,设置连接超时和读取超时。

spring:
  ai:
    openai:
      chat:
        options:
          model: gpt-3.5-turbo
      # 通过rest-client自定义配置
      rest-client:
        connect-timeout: 3s
        read-timeout: 10s

5. 生产避坑指南

5.1 模型版本灰度发布

直接切换AI模型(比如从gpt-3.5-turbo升级到gpt-4,或切换不同的本地模型)风险很高。新模型可能带来意想不到的输出格式或质量变化。

方案:采用流量染色和路由

  1. 在会话或用户维度打上标签(如model-version: stablemodel-version: canary)。
  2. 在调用ChatClient前,根据标签决定使用哪个模型配置(可以通过配置不同的ChatClient Bean实现)。
  3. 初期将少量流量(如5%)导向新模型(canary),通过日志和监控对比两者的响应质量、延迟和成本。
  4. 确认新模型稳定后,逐步放大流量,直至完全切换。

5.2 多租户会话隔离

一个SaaS客服平台要服务多个企业(租户)。必须确保A公司的用户数据绝不会泄露给B公司。

实现

  1. 数据键隔离:在Redis Key和数据库表设计中加入租户ID。
    • Redis Key: chat:tenant_{tenantId}:session_{sessionId}
    • 数据库查询:WHERE tenant_id = ?
  2. 运行时上下文:使用ThreadLocal或Spring Security的上下文来存储当前请求的租户ID,在服务层自动注入。
  3. AI模型隔离(可选):如果不同租户使用不同的AI模型或API Key,可以在ChatClient选择逻辑中加入租户判断。

6. 延伸思考:还能用SpringAI做什么?

SpringAI的能力不止于聊天。在这个客服系统基础上,我们可以低成本地添加增强功能:

  • 情感分析:在用户消息进入意图识别前,先调用SpringAI的EmbeddingClient或另一个专用的ChatClient(配置情感分析Prompt),判断用户情绪是“满意”、“一般”还是“愤怒”。对于情绪负面的对话,可以优先转接人工,或触发特定的安抚话术。

    // 简化的情感分析Prompt
    String sentimentPrompt = “判断以下用户语句的情感倾向,仅返回‘positive’, ‘neutral’, ‘negative’中的一个词。语句:” + userInput;
    // 调用轻量模型进行分析
    
  • 自动摘要与工单生成:当对话结束或转人工时,可以利用SpringAI的ChatClient,将整个对话历史总结成一段简洁的摘要,并自动提取关键信息(如问题类型、联系方式、解决状态)填入工单系统,极大提升客服效率。

  • 知识库动态增强:对于大模型未能很好回答的问题,可以记录下问答对。经过人工审核后,将其向量化并存入本地向量知识库。这样,同样的用户问题再次出现时,就能被本地模型快速准确地匹配到,实现了系统的自我进化。

SpringAI智能客服功能拓展

写在最后

通过这个项目,我深刻体会到,SpringAI最大的优势不是提供了多牛的AI算法,而是把AI能力变成了Spring开发者熟悉的“配方”。它让我们可以像集成数据库、消息队列一样,用声明式、配置化的方式集成AI,并充分利用Spring生态原有的稳定性设施(熔断、重试、监控等)。

从零搭建一个智能客服系统,技术选型、架构设计、性能优化、生产运维,每一步都有坑。但核心思路是清晰的:状态管理是骨架,AI模型是大脑,而Spring生态提供的各种工具则是让这个系统健壮运行的肌肉和神经。希望这篇实战笔记能为你带来一些启发,少走一些我们走过的弯路。

Logo

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

更多推荐