最近在做一个企业级的智能客服项目,客户要求系统能理解上下文、快速从知识库找答案,还得支持复杂的多轮对话。一开始觉得用Python生态的LangChain挺方便,但团队主力是Java,维护两套技术栈成本太高。后来发现了LangChain4j这个宝藏,它把LangChain的核心思想带到了JVM世界,让我们能用熟悉的Spring Boot快速搭建起智能客服系统。今天就来分享一下我们的实战经验,从架构设计到生产部署,希望能帮到有类似需求的同学。

智能客服系统架构示意图

1. 智能客服开发的三大核心挑战

在动手之前,我们先得搞清楚要解决什么问题。根据我们的项目经验,智能客服开发主要面临三大挑战:

  1. 对话状态维护成本高:用户不会一次把话说完。比如用户问“我想订机票”,客服得接着问“去哪里?什么时候?”,这就需要系统记住之前的对话内容。传统做法是用Session或者数据库存对话历史,但自己管理状态机非常复杂,容易出错。
  2. 知识检索效率低:客服系统背后通常有个庞大的知识库(产品手册、FAQ等)。当用户问“这个产品怎么保修?”时,系统需要从海量文档里快速找到相关段落。简单的关键词匹配(如SQL的LIKE)效果很差,而全文检索又难以理解语义。
  3. 多轮对话逻辑复杂:真实的业务对话不是一问一答。比如退货流程,可能涉及确认订单、选择原因、上传凭证、填写地址等多个步骤。如何优雅地编排这些步骤,并在适当时机跳转或回退,是设计上的难点。

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

市面上做对话系统的框架不少,比如Python的Rasa、微软的Bot Framework。我们最终选择LangChain4j,主要基于以下几点考虑:

  • JVM原生集成:这是决定性因素。我们的技术栈是Spring Boot + Java。LangChain4j可以直接作为依赖引入,无需额外维护Python服务或处理跨语言调用(gRPC/HTTP)带来的延迟和复杂度。所有组件(内存、检索器、链)都是纯Java对象,调试和监控非常方便。
  • 模块化设计:LangChain4j的核心概念是“链”(Chain)和“组件”。我们可以像搭积木一样,把对话记忆(ChatMemory)、检索器(Retriever)、语言模型(LLM)组合起来。这种设计让系统架构清晰,每个部分都可以独立替换或优化。
  • 强大的生态对接:它原生支持多种向量数据库(如Redis、Pinecone、Milvus)、多种嵌入模型(OpenAI、Local)和多种聊天模型(OpenAI GPT、Azure OpenAI、Ollama本地模型)。这意味着我们可以根据实际需求(成本、数据隐私、性能)灵活选择后端服务。
  • 易于测试:由于是纯Java库,我们可以用JUnit、Testcontainers轻松编写单元测试和集成测试,保证代码质量。

相比之下,Rasa虽然功能强大,但需要学习其特定的领域语言(YAML)和训练流程,对于Java团队来说学习曲线较陡。而LangChain4j让我们能继续用熟悉的工具和模式进行开发。

3. 核心实现:三大模块拆解

我们的系统核心主要由三部分组成:对话状态管理、知识检索和意图识别/响应生成。

3.1 使用ChatMemory实现对话状态管理

LangChain4j提供了ChatMemory接口来管理对话历史,这是解决“上下文理解”问题的关键。我们选择了MessageWindowChatMemory,它只保留最近N条消息,防止上下文过长导致模型性能下降或API调用超支。

import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory() {
        // 保留最近10轮对话作为上下文
        return MessageWindowChatMemory.withMaxMessages(10);
    }
}

在实际使用中,我们将ChatMemoryConversationalChain绑定。每次用户发起对话时,系统会自动将当前用户消息和历史记录一起发送给LLM,这样模型就能知道“上文”在聊什么了。

3.2 通过RetrievalQAChain构建知识检索模块

这是智能客服的“大脑”。我们采用RAG(检索增强生成)架构。简单说就是:先把公司知识库文档转换成向量(Embedding)存起来;当用户提问时,把问题也转换成向量,去数据库里找最相似的几个文档片段;最后让LLM基于这些片段生成答案。

  1. 文档加载与切分:我们使用DocumentLoaderDocumentSplitter来处理PDF、Word等格式的知识库文件,将大文档切成语义完整的小块(如500字符一段)。
  2. 向量化与存储:使用OpenAI的text-embedding-ada-002模型(维度1536)将文档块转换为向量,并存入Redis Stack的向量搜索模块。
  3. 构建检索链:核心是RetrievalQAChain,它封装了“检索->组装上下文->提问”的完整流程。
import dev.langchain4j.chain.ConversationalRetrievalChain;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiEmbeddingModel;
import dev.langchain4j.retriever.EmbeddingStoreRetriever;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.redis.RedisEmbeddingStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;

@Configuration
public class KnowledgeBaseConfig {

    @Value("${openai.api.key}")
    private String openAiApiKey;

    @Bean
    public EmbeddingModel embeddingModel() {
        // 使用OpenAI的嵌入模型,设置超时和最大重试
        return OpenAiEmbeddingModel.builder()
                .apiKey(openAiApiKey)
                .modelName("text-embedding-ada-002")
                .timeout(Duration.ofSeconds(60))
                .maxRetries(3)
                .build();
    }

    @Bean
    public EmbeddingStore<TextSegment> embeddingStore() {
        // 连接Redis向量数据库
        return RedisEmbeddingStore.builder()
                .host("localhost")
                .port(6379)
                .indexName("customer_service_kb")
                .dimension(1536) // 必须与embedding模型维度一致
                .build();
    }

    @Bean
    public ConversationalRetrievalChain qaChain(EmbeddingModel embeddingModel,
                                                EmbeddingStore<TextSegment> embeddingStore,
                                                ChatMemory chatMemory) {
        // 创建检索器,每次检索最相关的3个文档片段
        EmbeddingStoreRetriever retriever = EmbeddingStoreRetriever.from(embeddingStore, embeddingModel, 3);

        // 构建对话式检索链,将记忆、检索器和LLM组合起来
        return ConversationalRetrievalChain.builder()
                .chatLanguageModel(OpenAiChatModel.withApiKey(openAiApiKey)) // 这里需要注入LLM
                .retriever(retriever)
                .chatMemory(chatMemory)
                .build();
    }
}

3.3 自定义PromptTemplate优化意图识别

默认的提示词可能不适合我们的业务。比如,我们希望客服回答时更简洁、更专业,并且能主动询问缺失信息。通过自定义PromptTemplate,我们可以精准地控制模型的“行为”。

import dev.langchain4j.model.input.PromptTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class PromptConfig {

    @Bean
    public PromptTemplate customerServicePromptTemplate() {
        String template = """
                你是一个专业的客服助手。请根据以下上下文信息回答用户问题。
                如果上下文信息不足以回答问题,请礼貌地告知用户你无法回答,并建议其联系人工客服。
                请保持回答简洁、准确、友好。
                上下文:{{context}}
                历史对话:{{chat_history}}
                用户问题:{{question}}
                请用中文回答:
                """;
        return PromptTemplate.from(template);
    }
}

然后,在构建链时,将这个自定义的提示词模板注入进去,替换掉默认的模板。

4. Spring Boot集成与测试

接下来,我们把上面的组件在Spring Boot中组装起来,并确保其可靠运行。

4.1 服务层封装与异常处理

我们创建一个CustomerService来封装复杂的链调用,并加入健壮的异常处理和日志记录。

import dev.langchain4j.chain.ConversationalRetrievalChain;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;

@Service
@Slf4j
public class CustomerService {

    private final ConversationalRetrievalChain qaChain;

    public CustomerService(ConversationalRetrievalChain qaChain) {
        this.qaChain = qaChain;
    }

    public String chat(String sessionId, String userMessage) {
        StopWatch watch = new StopWatch();
        watch.start();
        try {
            log.info("会话[{}]收到用户消息:{}", sessionId, userMessage);
            // 执行对话链
            String answer = qaChain.execute(userMessage);
            log.info("会话[{}]生成回答成功,耗时:{}ms", sessionId, watch.getTotalTimeMillis());
            return answer;
        } catch (Exception e) {
            log.error("会话[{}]处理消息时发生异常,用户消息:{}", sessionId, userMessage, e);
            // 返回友好的错误信息,避免暴露内部细节
            return "抱歉,系统暂时无法处理您的请求,请稍后再试或联系人工客服。";
        } finally {
            watch.stop();
        }
    }
}

4.2 使用Testcontainers编写集成测试

为了保证代码质量,我们为关键的数据流编写集成测试。使用Testcontainers可以轻松启动一个真实的Redis实例用于测试。

import dev.langchain4j.store.embedding.EmbeddingStore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
class CustomerServiceIntegrationTest {

    // 启动一个Redis容器
    @Container
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis/redis-stack:latest"))
            .withExposedPorts(6379);

    @Autowired
    private CustomerService customerService;

    @Autowired
    private EmbeddingStore<?> embeddingStore;

    @Test
    void shouldAnswerBasedOnKnowledgeBase() {
        // 这里可以预先在embeddingStore中存入一些测试用的知识片段
        // ...

        String sessionId = "test-session-1";
        String answer = customerService.chat(sessionId, "你们公司的退货政策是什么?");

        assertThat(answer).isNotBlank();
        assertThat(answer).contains("退货"); // 简单断言回答与问题相关
        // 更复杂的断言可以检查回答中是否包含知识库中的特定关键词
    }
}

代码测试与集成流程

5. 性能优化实战

系统能跑起来之后,下一步就是让它跑得更快、更稳、更省钱。我们主要做了以下几点优化:

  1. 向量检索的批处理优化:如果知识库更新频繁,需要批量计算新文档的向量。直接循环调用Embedding API成本高且慢。我们改用异步批量处理,并利用Embedding模型通常支持的批处理特性(如OpenAI一次可处理最多2048个文本)。我们将待处理的文档列表分批(每批100个),并发地进行向量化,吞吐量提升了近10倍。
  2. 对话缓存的TTL策略MessageWindowChatMemory默认在内存中。对于生产环境,我们将其持久化到Redis,并设置合理的TTL(生存时间)。对于活跃会话,TTL设置为30分钟;对于包含敏感信息(如订单号)的对话,TTL缩短至10分钟,并在对话结束后主动清除。
  3. 线程池资源配置公式:LLM和Embedding API调用都是网络I/O密集型操作。我们使用异步编程(如CompletableFuture)来避免阻塞。线程池大小根据公式进行配置:线程数 = CPU核心数 * (1 + 平均等待时间 / 平均计算时间)。在我们的场景下,API调用等待时间远大于本地计算时间,因此我们配置了一个较大的I/O线程池(如核心数50,最大数200),并设置了合适的队列容量和拒绝策略,防止内存溢出。

6. 生产环境注意事项

把系统部署上线,还需要考虑安全、稳定性和可观测性。

  • 敏感信息过滤方案:用户可能在对话中提供手机号、身份证号等信息。我们在对话链的最前方增加了一个“过滤器”组件,使用正则表达式或预训练的NER(命名实体识别)模型扫描用户输入,将敏感信息替换为占位符(如[PHONE])后再交给LLM处理。同样,在存储对话日志前也会进行脱敏。
  • 对话日志脱敏存储:所有对话日志(用户输入、系统回复)在存入ES或数据库前,必须经过脱敏处理。我们不仅存储脱敏后的文本,还将原始文本的哈希值(如SHA-256)和脱敏规则ID一起存储,以备合规审计时在授权情况下进行还原。
  • 限流熔断实现
    • 限流:使用Resilience4j或Sentinel,为每个API Key或用户级别设置每秒请求数(RPS)限制,防止恶意调用或意外流量打垮后端LLM服务。
    • 熔断:当LLM API或向量数据库连续失败多次后,快速熔断,直接返回降级答案(如“服务繁忙,请稍后”),并启动一个后台线程定期探测依赖服务是否恢复。

7. 总结与展望

通过LangChain4j,我们成功用Java技术栈构建了一个功能完备、性能可控的智能客服系统。它解决了对话状态、知识检索和多轮逻辑的核心难题,并且得益于Spring Boot生态,集成和部署都非常顺畅。

当然,系统还有可完善之处。例如,目前我们的对话记忆和向量存储是全局的。如果未来要服务多个不同的客户(多租户),如何设计数据隔离策略?是使用独立的数据库实例,还是在同一个存储中通过tenant_id字段进行逻辑隔离?不同的隔离级别在性能、成本和运维复杂度上如何权衡?

如果你有好的想法或实践,欢迎在我们的GitHub仓库提交Issue或PR,一起探讨如何打造更强大的企业级智能对话平台。

Logo

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

更多推荐