Skip to content
第 249 / 250 章架构⏱ 18 分钟阅读

第 249 章:综合项目实战 - AI 客服系统

学习目标

  • 大模型应用集成
  • RAG 检索增强
  • 工单与会话
  • 智能路由与人机协同

一、系统架构

二、大模型集成

2.1 模型客户端

java
@Component
@Slf4j
public class LlmClient {

    @Value("${llm.api-key}")
    private String apiKey;

    @Value("${llm.endpoint}")
    private String endpoint;

    @Value("${llm.model:gpt-4}")
    private String model;

    private final RestTemplate restTemplate = new RestTemplate();

    /**
     * 同步聊天(非流式)
     */
    public ChatResponse chat(List<Message> messages) {
        ChatRequest request = new ChatRequest();
        request.setModel(model);
        request.setMessages(messages);
        request.setTemperature(0.7);
        request.setMaxTokens(2000);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.setBearerAuth(apiKey);

        HttpEntity<ChatRequest> entity = new HttpEntity<>(request, headers);

        ResponseEntity<ChatResponse> response = restTemplate.exchange(
            endpoint + "/chat/completions",
            HttpMethod.POST,
            entity,
            ChatResponse.class
        );

        return response.getBody();
    }

    /**
     * 流式聊天(SSE)
     */
    public Flux<String> streamChat(List<Message> messages) {
        ChatRequest request = new ChatRequest();
        request.setModel(model);
        request.setMessages(messages);
        request.setStream(true);

        // ...
        return webClient.post()
            .uri(endpoint + "/chat/completions")
            .header("Authorization", "Bearer " + apiKey)
            .bodyValue(request)
            .retrieve()
            .bodyToFlux(String.class)
            .map(this::parseSseData);
    }
}

2.2 Prompt 工程

java
public class PromptTemplate {

    /**
     * 客服 Prompt
     */
    public static final String CUSTOMER_SERVICE = """
        你是 {company} 的智能客服助手,名 {bot_name}。
        你的职责:专业、耐心地回答用户问题。

        知识上下文(可能为空):
        {context}

        回答要求:
        1. 基于知识上下文回答,如无相关信息请诚实告知
        2. 回答准确、简洁、有礼貌
        3. 必要时引导用户提供更多细节
        4. 如涉及金额、个人信息,请注意隐私保护
        5. 回答结尾可主动询问是否还有其他问题

        对话历史:
        {history}

        用户当前问题: {question}
        """;

    /**
     * 工单分类 Prompt
     */
    public static final String TICKET_CLASSIFY = """
        将用户问题分类到以下类别之一:
        {categories}

        输出格式(JSON):
        {{
            "category": "<类别>",
            "urgency": "<high|medium|low>",
            "summary": "<一句话总结>"
        }}

        用户问题: {question}
        """;
}

三、RAG 检索增强

3.1 RAG 流程

3.2 文档向量化

java
@Service
@Slf4j
public class DocumentIndexService {

    @Autowired
    private EmbeddingClient embeddingClient;

    @Autowired
    private VectorStore vectorStore;

    /**
     * 把文档切块并向量化
     */
    public void indexDocument(KbDocument doc) {
        // 1. 文档解析
        List<String> chunks = textSplitter.split(doc.getContent(), 500, 50);

        // 2. 每块生成 embedding
        List<DocumentChunk> vectors = new ArrayList<>();
        for (int i = 0; i < chunks.size(); i++) {
            String chunk = chunks.get(i);
            List<Float> embedding = embeddingClient.embed(chunk);

            DocumentChunk v = new DocumentChunk();
            v.setDocumentId(doc.getId());
            v.setChunkIndex(i);
            v.setContent(chunk);
            v.setEmbedding(embedding);
            vectors.add(v);
        }

        // 3. 写入向量库
        vectorStore.batchInsert(vectors);

        // 4. 同时写 ES 做关键词检索(混合检索)
        esClient.bulkIndex(chunks);
    }
}

@Component
public class TextSplitter {

    /**
     * 文本切块 - 按段落优先,过长再按句子
     */
    public List<String> split(String text, int chunkSize, int overlap) {
        List<String> chunks = new ArrayList<>();
        String[] paragraphs = text.split("\n\n");

        StringBuilder current = new StringBuilder();
        for (String para : paragraphs) {
            if (current.length() + para.length() > chunkSize && current.length() > 0) {
                chunks.add(current.toString().trim());
                // 保留 overlap
                current = new StringBuilder(getTail(current.toString(), overlap));
            }
            current.append(para).append("\n\n");
        }
        if (current.length() > 0) {
            chunks.add(current.toString().trim());
        }
        return chunks;
    }

    private String getTail(String text, int chars) {
        return text.length() <= chars ? text : text.substring(text.length() - chars);
    }
}

3.3 检索 + 重排

java
@Service
public class RagService {

    @Autowired
    private EmbeddingClient embeddingClient;

    @Autowired
    private VectorStore vectorStore;

    @Autowired
    private ElasticsearchClient esClient;

    @Autowired
    private RerankClient rerankClient;

    /**
     * RAG 检索
     */
    public List<DocumentChunk> retrieve(String question, int topK) {
        // 1. 向量化
        List<Float> queryVec = embeddingClient.embed(question);

        // 2. 向量检索(召回 top 50)
        List<DocumentChunk> candidates = vectorStore.search(queryVec, 50);

        // 3. 关键词检索(补充)
        List<DocumentChunk> keywordResults = keywordSearch(question, 30);
        candidates = mergeAndDedup(candidates, keywordResults);

        // 4. 重排序(精排 top 5)
        return rerankClient.rerank(question, candidates, topK);
    }

    private List<DocumentChunk> keywordSearch(String question, int topK) throws IOException {
        SearchRequest request = new SearchRequest("kb_chunks");
        SearchSourceBuilder builder = new SearchSourceBuilder()
            .query(QueryBuilders.matchQuery("content", question))
            .size(topK);

        SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);

        return Arrays.stream(response.getHits().getHits())
            .map(hit -> {
                try {
                    return JSON.parseObject(hit.getSourceAsString(), DocumentChunk.class);
                } catch (Exception e) {
                    return null;
                }
            })
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
    }

    private List<DocumentChunk> mergeAndDedup(
        List<DocumentChunk> a, List<DocumentChunk> b
    ) {
        Map<Long, DocumentChunk> map = new LinkedHashMap<>();
        for (DocumentChunk c : a) map.putIfAbsent(c.getId(), c);
        for (DocumentChunk c : b) map.putIfAbsent(c.getId(), c);
        return new ArrayList<>(map.values());
    }
}

四、AI 客服核心

4.1 会话服务

java
@Service
@Slf4j
public class ConversationService {

    @Autowired
    private LlmClient llmClient;

    @Autowired
    private RagService ragService;

    @Autowired
    private ConversationRepo conversationRepo;

    /**
     * 用户发消息
     */
    public AiReply sendMessage(String conversationId, String userMessage) {
        // 1. 加载会话
        Conversation conv = conversationRepo.findById(conversationId);

        // 2. 存用户消息
        conv.addMessage(new Message("user", userMessage, System.currentTimeMillis()));

        // 3. RAG 检索
        List<DocumentChunk> context = ragService.retrieve(userMessage, 5);
        String contextText = context.stream()
            .map(DocumentChunk::getContent)
            .collect(Collectors.joining("\n\n---\n\n"));

        // 4. 构建 Prompt
        String prompt = buildPrompt(conv, contextText, userMessage);

        // 5. 调用 LLM
        List<Message> history = trimHistory(conv.getMessages(), 10);  // 最近 10 轮
        history.add(new Message("user", prompt));

        ChatResponse response = llmClient.chat(history);
        AiMessage aiMsg = response.getChoices().get(0).getMessage();

        // 6. 存 AI 消息
        AiMessage persisted = new AiMessage(
            "assistant",
            aiMsg.getContent(),
            System.currentTimeMillis()
        );
        persisted.setSources(context.stream()
            .map(c -> new Source(c.getDocumentId(), c.getChunkIndex(), c.getContent().substring(0, 100)))
            .collect(Collectors.toList())
        );
        conv.addMessage(persisted);
        conversationRepo.save(conv);

        // 7. 评估是否转人工
        if (needHumanHandover(userMessage, aiMsg.getContent())) {
            triggerHandover(conv);
        }

        return new AiReply(persisted.getContent(), persisted.getSources());
    }

    private String buildPrompt(Conversation conv, String context, String question) {
        String historyText = conv.getMessages().stream()
            .filter(m -> !m.isSystem())
            .map(m -> m.getRole() + ": " + m.getContent())
            .collect(Collectors.joining("\n"));

        return PromptTemplate.CUSTOMER_SERVICE
            .replace("{company}", "小红书")
            .replace("{bot_name}", "小助手")
            .replace("{context}", context)
            .replace("{history}", historyText)
            .replace("{question}", question);
    }

    /**
     * 触发转人工
     */
    private boolean needHumanHandover(String userMsg, String aiResponse) {
        // 简单规则:
        // 1. 用户明确要求
        if (userMsg.contains("转人工") || userMsg.contains("真人客服")) {
            return true;
        }
        // 2. AI 置信度低
        // 3. AI 多次重复回答
        // 4. 情绪负向
        return false;
    }
}

4.2 流式输出

java
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    @Autowired
    private ConversationService conversationService;

    /**
     * SSE 流式响应
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> stream(@RequestParam String conversationId,
                                @RequestParam String message) {
        return Flux.create(sink -> {
            StringBuilder fullResponse = new StringBuilder();

            conversationService.streamMessage(conversationId, message,
                chunk -> {
                    fullResponse.append(chunk);
                    sink.next("data: " + JSON.toJSONString(Map.of("content", chunk)) + "\n\n");
                },
                () -> {
                    sink.next("data: [DONE]\n\n");
                    sink.complete();
                }
            );
        });
    }
}

五、工单系统

5.1 工单实体

java
@Data
@Entity
public class Ticket {

    @Id
    private Long id;

    private String ticketNo;

    private Long userId;

    private Long agentId;

    private String title;

    private String description;

    private TicketStatus status;  // OPEN, ASSIGNED, PROCESSING, RESOLVED, CLOSED

    private Priority priority;  // LOW, NORMAL, HIGH, URGENT

    private Long orderId;

    private String category;

    private LocalDateTime createdAt;

    private LocalDateTime closedAt;

    private Integer satisfaction;  // 1-5 星
}

public enum TicketStatus {
    OPEN,        // 待分配
    ASSIGNED,    // 已分配
    PROCESSING,  // 处理中
    RESOLVED,    // 已解决
    CLOSED       // 已关闭
}

5.2 智能分类

java
@Service
public class TicketClassifier {

    @Autowired
    private LlmClient llmClient;

    /**
     * 自动分类
     */
    public TicketClassification classify(String description) {
        String categories = """
            1. 账户类:登录、注册、密码、实名
            2. 订单类:下单、退款、修改订单
            3. 支付类:充值、提现、账单
            4. 售后类:换货、维修、投诉
            5. 产品类:商品咨询、功能问题、Bug
            6. 其他:反馈、建议、其他
            """;

        String prompt = PromptTemplate.TICKET_CLASSIFY
            .replace("{categories}", categories)
            .replace("{question}", description);

        ChatResponse response = llmClient.chat(List.of(
            new Message("system", "你是客服工单分类专家"),
            new Message("user", prompt)
        ));

        return JSON.parseObject(response.getChoices().get(0).getMessage().getContent(),
            TicketClassification.class);
    }
}

5.3 智能分配

java
@Service
public class TicketDispatchService {

    /**
     * 智能分配工单到坐席
     */
    public Long assignAgent(Ticket ticket) {
        // 1. 找该技能组可用坐席
        List<Agent> available = agentService.findAvailableBySkill(
            ticket.getCategory(),
            ticket.getPriority()
        );

        if (available.isEmpty()) {
            // 排队
            queueService.enqueue(ticket);
            return null;
        }

        // 2. 选最合适的坐席
        Agent best = available.stream()
            .min(Comparator.comparingInt(Agent::getCurrentTickets))
            .orElseThrow();

        // 3. 分配
        ticket.setAgentId(best.getId());
        ticket.setStatus(TicketStatus.ASSIGNED);
        ticketMapper.updateById(ticket);

        // 4. 通知坐席
        notificationService.notifyAgent(best.getId(), ticket);

        return best.getId();
    }
}

六、坐席工作台

6.1 智能辅助

java
@Service
public class AgentAssistant {

    @Autowired
    private LlmClient llmClient;

    @Autowired
    private RagService ragService;

    /**
     * 坐席实时推荐答案
     */
    public List<SuggestedReply> suggest(Long agentId, String userMessage) {
        // 1. 知识库推荐
        List<DocumentChunk> chunks = ragService.retrieve(userMessage, 3);

        // 2. LLM 生成最佳回复
        String prompt = String.format("""
            作为资深客服,为坐席提供回复建议。
            用户问题: %s
            知识参考: %s

            请输出 3 个建议(标准、专业、亲切 各一个):
            """, userMessage, chunks.stream().map(DocumentChunk::getContent)
            .collect(Collectors.joining("\n")));

        ChatResponse response = llmClient.chat(List.of(
            new Message("system", "你是客服专家"),
            new Message("user", prompt)
        ));

        return SuggestedReplyParser.parse(response.getChoices().get(0).getMessage().getContent());
    }

    /**
     * 自动摘要(对话历史)
     */
    public String summarize(List<Message> conversation) {
        String history = conversation.stream()
            .map(m -> m.getRole() + ": " + m.getContent())
            .collect(Collectors.joining("\n"));

        ChatResponse response = llmClient.chat(List.of(
            new Message("system", "请总结用户与客服的对话,提取关键信息和待办事项"),
            new Message("user", history)
        ));

        return response.getChoices().get(0).getMessage().getContent();
    }
}

6.2 情绪识别

java
@Service
public class EmotionService {

    @Autowired
    private LlmClient llmClient;

    /**
     * 实时识别用户情绪
     */
    public EmotionResult analyze(String userMessage) {
        String prompt = String.format("""
            分析用户文本的情绪,输出 JSON:
            {
                "emotion": "<happy/neutral/frustrated/angry>",
                "intensity": <0-1>,
                "reason": "<分析>"
            }

            用户文本: %s
            """, userMessage);

        ChatResponse response = llmClient.chat(List.of(
            new Message("system", "你是情感分析专家"),
            new Message("user", prompt)
        ));

        return JSON.parseObject(response.getChoices().get(0).getMessage().getContent(),
            EmotionResult.class);
    }
}

七、AI 训练数据闭环

java
@Service
@Slf4j
public class TrainingDataCollector {

    @Autowired
    private KafkaTemplate<String, TrainingData> kafkaTemplate;

    /**
     * 收集对话数据(用于改进模型)
     */
    public void collect(String conversationId) {
        Conversation conv = conversationRepo.findById(conversationId);

        TrainingData data = new TrainingData();
        data.setConversationId(conversationId);
        data.setMessages(conv.getMessages());
        data.setResolved(conv.getStatus() == ConversationStatus.RESOLVED);
        data.setSatisfaction(conv.getSatisfaction());
        data.setUserRating(conv.getUserRating());

        // 上报到训练数据平台
        kafkaTemplate.send("training-data", data);
    }

    /**
     * 上传到 RAG 知识库(知识运营)
     */
    public void uploadToKnowledge(TrainingData data) {
        if (!data.isResolved() || data.getSatisfaction() < 4) {
            return;
        }

        KbDocument doc = new KbDocument();
        doc.setTitle("用户问题: " + truncate(data.getMessages().get(0).getContent()));
        doc.setContent(serializeConversation(data.getMessages()));
        doc.setSource("training");
        doc.setUploadedAt(LocalDateTime.now());
        doc.setUploadedBy("system");

        documentService.save(doc);

        // 异步向量化
        kafkaTemplate.send("doc-index", doc.getId());
    }
}

八、效果评估

8.1 评估指标

yaml
解决率:
  - AI 独立解决率: AI 完成工单的比例
  - 转人工率: AI 转人工的比例
  - 解决率(7 天内关闭): 已解决工单比例

效率:
  - 平均响应时间
  - 平均处理时长
  - 单工单成本

满意度:
  - CSAT: 客服满意度
  - NPS: 推荐意愿
  - 用户差评率

8.2 A/B 测试

java
@Component
public class LlmExperiment {

    @Autowired
    private LlmClient llmClient;

    private static final double CONTROL_RATE = 0.5;

    /**
     * 不同模型的 A/B 测试
     */
    public String chatWithExperiment(String userId, String conversationId, String message) {
        String version = bucketService.getBucket(userId);

        if ("control".equals(version)) {
            return llmClient.chat(modelV1, message).getContent();
        } else {
            // 实验组:GPT-4
            return llmClient.chat(modelV4, message).getContent();
        }
    }
}

九、本章小结

模块关键
LLMPrompt + 流式 + 多模型
RAG文档切块 + 向量 + 重排
会话上下文 + 转人工
工单智能分类 + 智能分配
辅助实时推荐 + 情绪识别

动手练习

  1. 用 Spring AI 集成 OpenAI,实现一个简单聊天
  2. 设计一个 RAG 知识库的数据流
  3. 实现一个工单自动分类 demo
  4. 调研你公司用的大模型场景

下一章:下一章:第 250 章:总结与未来

本站基于 VitePress 构建 · 由 Codebook 团队维护