第 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();
}
}
}九、本章小结
| 模块 | 关键 |
|---|---|
| LLM | Prompt + 流式 + 多模型 |
| RAG | 文档切块 + 向量 + 重排 |
| 会话 | 上下文 + 转人工 |
| 工单 | 智能分类 + 智能分配 |
| 辅助 | 实时推荐 + 情绪识别 |
动手练习
- 用 Spring AI 集成 OpenAI,实现一个简单聊天
- 设计一个 RAG 知识库的数据流
- 实现一个工单自动分类 demo
- 调研你公司用的大模型场景
下一章:下一章:第 250 章:总结与未来