Vue3 + Spring Boot + 百鍊:AI 客服對話系統全棧實戰
Vue3 + Spring Boot + 百炼:AI 客服对话系统的全栈实战
作者以其負責的 SaaS 客服升級項目為例,示範用 Vue3、Spring Boot 和阿里雲百鍊搭建 AI 客服系統,並涵蓋知識庫、流式對接、上下文管理及人工轉接。文中稱系統上線後 AI 自主解決率達 70%,平均首次回應時間由 5 分鐘降至 2 秒,客戶滿意度由 72% 升至 93%。文章並提供 5 個生產環境問題處理示例、客服方案選型決策樹和上線檢查清單。
简介: 10个客服每天处理800条咨询,平均响应5分钟,夜间完全无人值班——这是很多企业客服团队的真实困境。本文从实际项目出发,完整演示 Vue3 + Spring Boot + 阿里云百炼搭建 AI 智能客服系统的全流程:百炼平台配置 → 知识库构建 → DashScope SDK 流式对接 → 多轮上下文管理 → 人工转接 → 对话评价统计 → Vue3 对话组件 → 管理后台 → 进阶能力,最终 70% 问题秒级响应,夜间全覆盖,客户满意度从72%提升到93%。文中包含5个生产踩坑实录和完整的AI客服选型决策树。
Vue3 + Spring Boot + 百炼:AI 客服对话系统的全栈实战
摘要:10个客服每天处理800条咨询,平均响应5分钟,夜间完全无人值班——这是很多企业客服团队的真实困境。本文从实际项目出发,完整演示 Vue3 + Spring Boot + 阿里云百炼搭建 AI 智能客服系统的全流程:百炼平台配置 → 知识库构建 → DashScope SDK 流式对接 → 多轮上下文管理 → 人工转接 → 对话评价统计 → Vue3 对话组件 → 管理后台 → 进阶能力,最终 70% 问题秒级响应,夜间全覆盖,客户满意度从72%提升到93%。文中包含5个生产踩坑实录和完整的AI客服选型决策树。
1. 场景:10个客服800条咨询的至暗时刻
去年我负责一家SaaS企业的客服系统升级项目,面对的是一个典型的人效困局:10个客服每天处理800+条咨询,平均响应时间5分钟,夜间(22:00-08:00)完全没有人工值班——用户只能留言等第二天回复。更棘手的是,重复问题占比超过60%,"怎么重置密码""账单怎么看""如何升级套餐"这些问题每天被问几十遍,客服回答到麻木。
上线 AI 客服系统后,一个季度的数据对比:

| 指标 | 改造前 | 改造后 | 提升幅度 |
|---|---|---|---|
| AI 自主解决率 | 0% | 70% | 🆕 新增 |
| 平均首次响应时间 | 5分钟 | 2秒 | ⬇️ 99.3% |
| 夜间服务覆盖率 | 0% | 100% | 🆕 全覆盖 |
| 客户满意度 | 72% | 93% | ⬆️ 29% |
| 人工客服负载 | 800条/天 | 240条/天 | ⬇️ 70% |
| 重复问题处理量 | 480条/天 | 0条 | ⬇️ 100% |
核心转变:70% 的高频重复问题由 AI 秒级响应,人工客服只处理需要判断力的复杂问题,夜间不再有服务空白。
2. 客服系统的5大痛点
传统客服系统为什么效率低下?根据我的实战经验,核心痛点有五个:

痛点一:响应慢是最直观的用户体验杀手。高峰期用户排队等30分钟才能连上客服,等待过程中退出率超过40%——用户没等到回答就走了。
痛点二:夜间无服务是很多企业的盲区。10个客服排三班倒成本太高,但不排班就意味着用户夜间问题要等到第二天。对于全球化业务,时区差异让这个问题更加严重。
痛点三:重复问题多是最浪费人力的地方。密码重置、账单查询、套餐说明这类标准化问题,客服闭着眼都能答,但每天还是要机械式重复几百遍,既消耗人力又消磨耐心。
3. AI 客服架构设计
3.1 整体架构

3.2 核心对话流程
一次完整的 AI 客服对话时序:

3.3 数据库设计
-- 会话表
CREATE TABLE chat_session (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) NOT NULL COMMENT '会话ID',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
channel VARCHAR(20) NOT NULL COMMENT 'WEB/H5/MINI',
status VARCHAR(20) NOT NULL COMMENT 'AI/TRANSFERRED/CLOSED',
agent_id VARCHAR(64) COMMENT '人工客服ID(转接后)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_session_id (session_id),
KEY idx_user_id (user_id),
KEY idx_status (status)
) ENGINE=InnoDB COMMENT='会话表';
-- 对话消息表
CREATE TABLE chat_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) NOT NULL COMMENT '会话ID',
role VARCHAR(20) NOT NULL COMMENT 'USER/ASSISTANT/SYSTEM',
content TEXT NOT NULL COMMENT '消息内容',
source VARCHAR(20) NOT NULL COMMENT 'AI/HUMAN',
knowledge_refs JSON COMMENT '引用的知识库片段',
tokens_used INT COMMENT '消耗Token数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_session_id (session_id),
KEY idx_created_at (created_at)
) ENGINE=InnoDB COMMENT='对话消息表';
-- 对话评价表
CREATE TABLE chat_feedback (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) NOT NULL,
message_id BIGINT NOT NULL COMMENT '评价的消息ID',
rating TINYINT NOT NULL COMMENT '1-5星',
resolved TINYINT(1) NOT NULL COMMENT '是否解决问题 0/1',
comment VARCHAR(512) COMMENT '用户备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_session_id (session_id),
KEY idx_rating (rating)
) ENGINE=InnoDB COMMENT='对话评价表';
-- 知识库文档表
CREATE TABLE kb_document (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
doc_id VARCHAR(64) NOT NULL COMMENT '文档ID',
title VARCHAR(256) NOT NULL COMMENT '文档标题',
category VARCHAR(64) COMMENT '分类',
file_path VARCHAR(512) COMMENT 'OSS路径',
file_type VARCHAR(20) COMMENT 'PDF/WORD/HTML/MD',
chunk_count INT COMMENT '分片数量',
status VARCHAR(20) NOT NULL COMMENT 'PARSING/READY/FAILED',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_doc_id (doc_id),
KEY idx_category (category)
) ENGINE=InnoDB COMMENT='知识库文档表';
4. 百炼平台配置:五步搭建 AI 客服
4.1 创建百炼应用
进入 百炼控制台 → 应用管理 → 创建应用 → 选择"对话型应用"。
应用创建后获得两个关键参数:
- AppId:应用唯一标识,用于 API 调用
- API-KEY:DashScope 鉴权密钥,从 API-KEY 管理页面创建
4.2 知识库构建
客服场景的知识库质量直接决定 AI 回答的准确性。我按照以下分类组织知识库:
| 知识库 | 文档来源 | 分片策略 | 更新频率 |
|---|---|---|---|
| 产品FAQ | 运营整理的问答对 | 按问答对切分 | 每周 |
| 操作手册 | 产品文档团队 | 按标题层级切分(512 token) | 版本发布时 |
| 计费说明 | 财务部门 | 按段落切分(1024 token) | 月度 |
| 故障案例 | 技术支持团队 | 按案例切分 | 实时 |
| 政策法规 | 法务部门 | 按章节切分 | 季度 |
关键配置:
# 百炼知识库配置
knowledge_base:
embedding_model: text-embedding-v3 # v3 支持中英混合
retrieval_type: hybrid # 混合检索
vector_weight: 0.7 # 语义权重
bm25_weight: 0.3 # 关键词权重
top_k: 20 # 召回数量
rerank_top_n: 5 # 重排保留
rerank_model: gte-rerank # 重排模型
4.3 模型选择
客服场景对模型的要求:回答准确、格式规范、不能胡编。
| 模型 | 适用场景 | 响应速度 | 价格 |
|---|---|---|---|
| Qwen-Plus | 通用客服问答(推荐默认) | 快 | 2元/百万Token |
| Qwen-Max | 复杂推理、技术支持 | 中 | 20元/百万Token |
| Qwen-Turbo | 简单FAQ、高峰分流 | 最快 | 0.3元/百万Token |
我的选择:Qwen-Plus 作为默认模型,覆盖90%场景;遇到技术支持类复杂问题自动升级到 Qwen-Max。
4.4 Prompt 模板
Prompt 是 AI 客服的"人格设定",直接决定回答风格和质量:
你是{公司名}的专业客服助手,你的职责是准确、友好地回答用户问题。
## 行为规范
1. 只回答知识库中有的内容,不确定时明确告知并建议转人工
2. 回答简洁明了,优先给出解决方案,再补充解释
3. 不编造任何不存在的产品功能、价格或政策
4. 涉及账户安全、退款等敏感操作,引导用户转人工处理
5. 保持专业友好的语气,不使用过于随意的网络用语
## 回答格式
- 操作类问题:分步骤说明,步骤用编号标记
- 概念类问题:先给结论,再展开解释
- 无法确定时:明确说"这个问题我暂时无法确认,建议您转人工客服获取准确答复"
## 特殊指令
- 如果用户情绪激动或表达不满,主动致歉并建议转人工
- 如果问题涉及多个知识点,综合回答而非碎片化罗列
4.5 对话策略
百炼支持灵活的对话策略配置,客服场景关键策略:
| 策略 | 配置 | 说明 |
|---|---|---|
| 上下文轮数 | 10轮 | 保留最近10轮对话作为上下文 |
| 温度 | 0.1 | 低温度减少幻觉 |
| Top-P | 0.85 | 适度限制生成空间 |
| 重复惩罚 | 1.1 | 避免重复内容 |
| 最大输出 | 2048 token | 限制单次回答长度 |
5. 后端 Spring Boot 实战
5.1 项目初始化与依赖
Why:使用 Spring AI + DashScope SDK 对接百炼,比裸调 REST API 更稳定,SSE 流式输出也更好管理。
<!-- pom.xml 核心依赖 -->
<properties>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<!-- Spring AI + 阿里云 DashScope -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring Web + SSE -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Tair 客户端 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>alibabacloud-tairjedis-sdk</artifactId>
<version>1.5.0</version>
</dependency>
<!-- PolarDB MySQL -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.24</version>
</dependency>
<!-- OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.18.1</version>
</dependency>
</dependencies>
# application.yml
spring:
ai:
dashscope:
api-key: ${
DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
temperature: 0.1
top-p: 0.85
embedding:
options:
model: text-embedding-v3
# Tair 配置
tair:
host: ${
TAIR_HOST}
port: 6379
password: ${
TAIR_PASSWORD}
# OSS 配置
alibaba:
cloud:
oss:
endpoint: oss-cn-hangzhou.aliyuncs.com
bucket: ${
OSS_BUCKET}
5.2 百炼 API 对接——流式 SSE
Why:客服场景用户体验的核心是"实时感",流式输出让用户看到 AI 逐字生成,而不是等5秒后一次性返回大段文字。
@RestController
@RequestMapping("/api/chat")
@Slf4j
public class ChatController {
private final ChatService chatService;
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(
@RequestParam String sessionId,
@RequestParam String message,
@RequestParam(defaultValue = "WEB") String channel) {
// 超时设置5分钟,防止长对话中断
SseEmitter emitter = new SseEmitter(300_000L);
// 异步流式处理
chatService.streamChat(sessionId, message, channel)
.subscribe(
chunk -> {
try {
emitter.send(SseEmitter.event()
.data(chunk)
.id(String.valueOf(System.currentTimeMillis())));
} catch (IOException e) {
log.warn("SSE推送失败: sessionId={}", sessionId, e);
emitter.completeWithError(e);
}
},
emitter::completeWithError,
() -> {
try {
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
} catch (IOException e) {
log.warn("SSE完成信号发送失败", e);
}
}
);
// 连接断开时清理资源
emitter.onTimeout(() -> log.info("SSE连接超时: sessionId={}", sessionId));
emitter.onError(e -> log.warn("SSE连接异常: sessionId={}", sessionId, e));
return emitter;
}
}
对话服务核心实现:
@Service
@Slf4j
public class ChatService {
private final DashScopeChatModel chatModel;
private final SessionContextManager contextManager;
private final KnowledgeService knowledgeService;
private final ChatMessageRepository messageRepository;
/**
* 流式对话:上下文加载 → 知识检索 → Prompt组装 → 流式生成
*/
public Flux<String> streamChat(String sessionId, String userMessage, String channel) {
// 1. 加载历史上下文
List<Message> history = contextManager.loadContext(sessionId);
// 2. 知识库检索
List<KnowledgeChunk> relevantDocs = knowledgeService.search(userMessage, 5);
// 3. 组装 Prompt
String systemPrompt = buildSystemPrompt(relevantDocs);
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
messages.addAll(history);
messages.add(new UserMessage(userMessage));
// 4. 调用百炼流式生成
Prompt prompt = new Prompt(messages, ChatOptionsBuilder.builder()
.withModel("qwen-plus")
.withTemperature(0.1)
.build());
StringBuilder fullResponse = new StringBuilder();
return chatModel.stream(prompt)
.map(chatResponse -> {
String content = chatResponse.getResult()
.getOutput().getText();
if (content != null) {
fullResponse.append(content);
}
return content != null ? content : "";
})
.filter(content -> !content.isEmpty())
.doOnComplete(() -> {
// 5. 异步保存对话记录和更新上下文
saveAndRefreshContext(sessionId, userMessage,
fullResponse.toString(), channel, relevantDocs);
});
}
/**
* 组装系统提示词:角色设定 + 检索到的知识片段
*/
private String buildSystemPrompt(List<KnowledgeChunk> docs) {
StringBuilder sb = new StringBuilder();
sb.append("你是专业的客服助手,请根据以下参考资料回答用户问题。\n\n");
sb.append("## 参考资料\n\n");
for (int i = 0; i < docs.size(); i++) {
sb.append("[").append(i + 1).append("] ").append(docs.get(i).getContent()).append("\n\n");
}
sb.append("## 要求\n");
sb.append("- 优先使用参考资料中的信息回答\n");
sb.append("- 参考资料中没有的内容,明确告知不确定并建议转人工\n");
sb.append("- 不要编造任何信息\n");
return sb.toString();
}
}
5.3 知识库管理
Why:客服知识库是动态的——产品更新、价格调整、新功能上线都需要及时更新知识库。自动化的文档入库管道比手动上传高效得多。
@Service
@Slf4j
public class KnowledgeService {
private final DashScopeEmbeddingModel embeddingModel;
private final OssClient ossClient;
private final KbDocumentRepository documentRepository;
private final BailianClient bailianClient;
/**
* 上传文档到知识库:OSS存储 → 百炼解析 → 向量化 → 入库
*/
@Transactional
public KbDocument uploadDocument(MultipartFile file, String category) {
// 1. 上传到 OSS
String ossPath = "kb/" + category + "/" + UUID.randomUUID() + getExtension(file);
ossClient.putObject(ossPath, file.getInputStream());
// 2. 创建文档记录
KbDocument doc = KbDocument.builder()
.docId("doc-" + IdWorker.getId())
.title(file.getOriginalFilename())
.category(category)
.filePath(ossPath)
.fileType(getFileType(file))
.status("PARSING")
.build();
documentRepository.save(doc);
// 3. 调用百炼知识库API:添加文档
bailianClient.addDocument(doc.getDocId(), ossPath, category);
return doc;
}
/**
* 知识库检索:查询改写 → 混合检索 → 重排
*/
public List<KnowledgeChunk> search(String query, int topN) {
// 调用百炼检索API(内部已实现向量+BM25混合检索+重排)
return bailianClient.retrieve(query, topN);
}
/**
* 增量更新:文档变更后重新解析入库
*/
public void refreshDocument(String docId) {
KbDocument doc = documentRepository.findByDocId(docId)
.orElseThrow(() -> new BusinessException("文档不存在"));
// 先删除旧索引,再重新入库
bailianClient.removeDocument(docId);
bailianClient.addDocument(docId, doc.getFilePath(), doc.getCategory());
doc.setStatus("PARSING");
documentRepository.save(doc);
}
}
百炼客户端封装:
@Component
@Slf4j
public class BailianClient {
@Value("${spring.ai.dashscope.api-key}")
private String apiKey;
private static final String BASE_URL = "https://dashscope.aliyuncs.com/api/v1";
/**
* 知识库检索:调用百炼 Retrieve API
*/
public List<KnowledgeChunk> retrieve(String query, int topN) {
// 构建检索请求
Map<String, Object> requestBody = Map.of(
"model", "qwen-plus",
"input", Map.of(
"query", query,
"top_k", topN
),
"parameters", Map.of(
"result_count", topN,
"rerank_top_n", topN
)
);
// 调用百炼检索接口
ResponseEntity<RetrieveResponse> response = restTemplate.exchange(
BASE_URL + "/apps/{appId}/completion",
HttpMethod.POST,
new HttpEntity<>(requestBody, buildHeaders()),
RetrieveResponse.class,
getAppId()
);
return parseRetrieveResult(response.getBody());
}
/**
* 添加文档到百炼知识库
*/
public void addDocument(String docId, String ossPath, String category) {
Map<String, Object> body = Map.of(
"category_id", category,
"file_url", ossPath,
"chunk_strategy", "auto"
);
restTemplate.postForEntity(
BASE_URL + "/indices/{indexId}/documents",
new HttpEntity<>(body, buildHeaders()),
Void.class
);
}
private HttpHeaders buildHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);
return headers;
}
}
5.4 多轮上下文管理
Why:客服对话是多轮的,"然后呢""那怎么退款""之前的订单呢"这些追问必须基于历史上下文才能正确理解。上下文管理不好,AI 就会"失忆"。
@Service
@Slf4j
public class SessionContextManager {
private final TairClient tairClient;
private static final int MAX_CONTEXT_ROUNDS = 10;
private static final int SESSION_TTL_HOURS = 24;
private static final String KEY_PREFIX = "chat:ctx:";
/**
* 加载会话上下文:从Tair缓存读取最近N轮对话
*/
public List<Message> loadContext(String sessionId) {
String key = KEY_PREFIX + sessionId;
String cached = tairClient.get(key);
if (cached == null) {
return new ArrayList<>();
}
List<ContextEntry> entries = JsonUtils.parseList(cached, ContextEntry.class);
// 只保留最近N轮
if (entries.size() > MAX_CONTEXT_ROUNDS * 2) {
entries = entries.subList(entries.size() - MAX_CONTEXT_ROUNDS * 2, entries.size());
}
return entries.stream()
.map(e -> "user".equals(e.getRole())
? (Message) new UserMessage(e.getContent())
: (Message) new AssistantMessage(e.getContent()))
.collect(Collectors.toList());
}
/**
* 追加上下文:用户消息+AI回复一起存入
*/
public void appendContext(String sessionId, String userMsg, String assistantMsg) {
String key = KEY_PREFIX + sessionId;
List<ContextEntry> entries = new ArrayList<>(loadRawEntries(key));
entries.add(new ContextEntry("user", userMsg));
entries.add(new ContextEntry("assistant", assistantMsg));
// 超过上限则截断,保留最近N轮
if (entries.size() > MAX_CONTEXT_ROUNDS * 2 + 2) {
entries = entries.subList(entries.size() - MAX_CONTEXT_ROUNDS * 2, entries.size());
}
tairClient.setex(key, SESSION_TTL_HOURS * 3600, JsonUtils.toJson(entries));
}
}
5.5 人工转接
Why:AI 不可能解决所有问题——账户异常、退款争议、情绪激动的用户都需要人工介入。关键是如何让 AI 自己判断何时转接,以及转接后会话历史无缝同步给人工客服。
@Service
@Slf4j
public class TransferService {
private final ChatSessionRepository sessionRepository;
private final ChatMessageRepository messageRepository;
private final AgentPoolManager agentPool;
private final SimpMessagingTemplate messagingTemplate;
/**
* AI判断是否需要转人工
*/
public TransferDecision evaluateTransfer(String sessionId, String userMessage,
String aiResponse) {
// 规则1:用户明确要求转人工
if (containsTransferKeyword(userMessage)) {
return TransferDecision.needTransfer("用户主动要求转人工", null);
}
// 规则2:AI回答中包含"建议转人工"
if (aiResponse.contains("建议您转人工") || aiResponse.contains("建议联系人工客服")) {
return TransferDecision.needTransfer("AI判断无法解决", "AI_LIMITATION");
}
// 规则3:同一问题连续3轮未解决
int unresolvedCount = countUnresolvedRounds(sessionId);
if (unresolvedCount >= 3) {
return TransferDecision.needTransfer("连续" + unresolvedCount + "轮未解决", "UNRESOLVED");
}
// 规则4:情绪检测(负面关键词命中)
if (detectNegativeEmotion(userMessage)) {
return TransferDecision.needTransfer("用户情绪负面", "EMOTIONAL");
}
return TransferDecision.noTransfer();
}
/**
* 执行转接:分配人工客服 → 同步会话历史 → 通知双方
*/
@Transactional
public TransferResult doTransfer(String sessionId, String reason) {
// 1. 分配空闲人工客服
Agent agent = agentPool.assignAgent();
if (agent == null) {
return TransferResult.fail("当前无空闲客服,请稍后再试");
}
// 2. 更新会话状态
ChatSession session = sessionRepository.findBySessionId(sessionId)
.orElseThrow(() -> new BusinessException("会话不存在"));
session.setStatus("TRANSFERRED");
session.setAgentId(agent.getAgentId());
sessionRepository.save(session);
// 3. 加载AI对话历史,发送给人工客服
List<ChatMessage> history = messageRepository.findBySessionIdOrderByCreatedAt(sessionId);
String summary = generateSessionSummary(history);
// 4. WebSocket 通知人工客服
messagingTemplate.convertAndSendToUser(
agent.getAgentId(),
"/queue/transfer",
new TransferNotification(sessionId, session.getUserId(), reason, summary)
);
// 5. 通知用户已转接
messagingTemplate.convertAndSendToUser(
session.getUserId(),
"/queue/chat/" + sessionId,
"已为您转接人工客服 " + agent.getName() + ",请稍候..."
);
return TransferResult.success(agent.getAgentId(), agent.getName());
}
/**
* 生成会话摘要:让人工客服快速了解前情
*/
private String generateSessionSummary(List<ChatMessage> history) {
// 取最近5轮对话,用 Qwen-Turbo 生成摘要
String recentChat = history.stream()
.skip(Math.max(0, history.size() - 10))
.map(m -> m.getRole() + ": " + m.getContent())
.collect(Collectors.joining("\n"));
// 调用摘要生成(使用轻量模型,降低成本)
Prompt summaryPrompt = new Prompt(List.of(
new SystemMessage("请用2-3句话概括以下客服对话的核心问题和已尝试的解决方案:"),
new UserMessage(recentChat)
), ChatOptionsBuilder.builder().withModel("qwen-turbo").build());
return chatModel.call(summaryPrompt).getResult().getOutput().getText();
}
}
5.6 对话评价与统计
Why:没有数据就无法优化。对话评价和统计分析帮助我们了解 AI 客服的实际效果,发现知识库的薄弱环节。
@Service
@Slf4j
public class FeedbackService {
private final ChatFeedbackRepository feedbackRepository;
private final ChatMessageRepository messageRepository;
/**
* 提交评价
*/
public void submitFeedback(String sessionId, Long messageId, int rating,
boolean resolved, String comment) {
ChatFeedback feedback = ChatFeedback.builder()
.sessionId(sessionId)
.messageId(messageId)
.rating(rating)
.resolved(resolved)
.comment(comment)
.build();
feedbackRepository.save(feedback);
}
/**
* 统计看板数据:解决率 + 满意度 + 热门问题
*/
public DashboardData getDashboard(LocalDate start, LocalDate end) {
// 整体解决率
double resolvedRate = feedbackRepository.avgResolvedRate(start, end);
// 平均满意度(1-5星)
double avgRating = feedbackRepository.avgRating(start, end);
// 热门问题TOP10:基于对话消息聚类
List<HotTopic> hotTopics = messageRepository.findTopTopics(start, end, 10);
// AI自主解决率
double aiSolveRate = messageRepository.aiSolveRate(start, end);
// 转人工原因分布
List<TransferReason> transferReasons = messageRepository.transferReasonDistribution(start, end);
return DashboardData.builder()
.resolvedRate(resolvedRate)
.avgRating(avgRating)
.hotTopics(hotTopics)
.aiSolveRate(aiSolveRate)
.transferReasons(transferReasons)
.build();
}
}
6. 前端 Vue3 实战
6.1 项目初始化
Why:Vue3 Composition API + TypeScript 是当前前端主流方案,Vite 开发体验丝滑,Element Plus 组件丰富。
npm create vite@latest ai-customer-service -- --template vue-ts
cd ai-customer-service
npm install element-plus @element-plus/icons-vue
npm install markdown-it highlight.js
npm install axios
6.2 对话组件——消息列表 + 流式渲染
Why:对话组件是 AI 客服的核心交互界面,流式打字效果和 Markdown 渲染是用户体验的关键。
<!-- ChatWindow.vue -->
<template>
<div class="chat-window">
<!-- 消息列表 -->
<div class="message-list" ref="messageListRef">
<div
v-for="msg in messages"
:key="msg.id"
:class="['message-item', msg.role === 'user' ? 'user' : 'assistant']"
>
<div class="avatar">
<el-avatar :size="36" :src="msg.role === 'user' ? userAvatar : aiAvatar" />
</div>
<div class="message-content">
<!-- 流式渲染中显示打字光标 -->
<div v-if="msg.streaming" class="markdown-body" v-html="renderMarkdown(msg.content)">
</div>
<div v-else class="markdown-body" v-html="renderMarkdown(msg.content)">
</div>
<span v-if="msg.streaming" class="typing-cursor">▊</span>
</div>
</div>
<!-- AI正在输入指示器 -->
<div v-if="isStreaming && !lastMsgHasContent" class="typing-indicator">
<el-avatar :size="36" :src="aiAvatar" />
<div class="dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
<!-- 评价弹窗 -->
<FeedbackDialog
v-model:visible="feedbackVisible"
:message-id="feedbackMessageId"
@submit="handleFeedback"
/>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import FeedbackDialog from './FeedbackDialog.vue'
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
streaming?: boolean
timestamp: number
}
const messages = ref<ChatMessage[]>([])
const messageListRef = ref<HTMLElement>()
const isStreaming = ref(false)
const feedbackVisible = ref(false)
const feedbackMessageId = ref<number>(0)
// Markdown 渲染器配置:支持代码高亮
const md = new MarkdownIt({
html: false, // 禁止HTML标签(防XSS)
linkify: true,
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`
} catch (_) { /* fallback */ }
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
const renderMarkdown = (content: string) => md.render(content)
// 自动滚动到底部
watch(() => messages.value.length, async () => {
await nextTick()
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
})
// SSE 流式接收
const sendMessage = async (text: string) => {
// 添加用户消息
const userMsg: ChatMessage = {
id: `msg-${Date.now()}`,
role: 'user',
content: text,
timestamp: Date.now()
}
messages.value.push(userMsg)
// 创建AI消息占位
const aiMsg: ChatMessage = {
id: `msg-${Date.now() + 1}`,
role: 'assistant',
content: '',
streaming: true,
timestamp: Date.now()
}
messages.value.push(aiMsg)
isStreaming.value = true
// 建立SSE连接
const eventSource = new EventSource(
`/api/chat/stream?sessionId=${sessionId}&message=${encodeURIComponent(text)}&channel=WEB`
)
eventSource.onmessage = (event) => {
if (event.data === '[DONE]') {
aiMsg.streaming = false
isStreaming.value = false
eventSource.close()
// 回答完成后弹出评价
feedbackMessageId.value = parseInt(aiMsg.id.split('-')[1])
feedbackVisible.value = true
return
}
// 增量追加内容
aiMsg.content += event.data
}
eventSource.onerror = () => {
aiMsg.streaming = false
isStreaming.value = false
eventSource.close()
if (!aiMsg.content) {
aiMsg.content = '抱歉,服务暂时不可用,请稍后重试或转人工客服。'
}
}
}
defineExpose({ sendMessage })
</script>
<style scoped>
.chat-window {
display: flex;
flex-direction: column;
height: 100%;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.message-item {
display: flex;
margin-bottom: 16px;
gap: 8px;
}
.message-item.user {
flex-direction: row-reverse;
}
.message-content {
max-width: 70%;
padding: 10px 14px;
border-radius: 12px;
line-height: 1.6;
}
.message-item.user .message-content {
background: #409eff;
color: white;
}
.message-item.assistant .message-content {
background: #f4f4f5;
color: #303133;
}
.typing-cursor {
animation: blink 1s infinite;
color: #409eff;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.typing-indicator {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #c0c4cc;
animation: bounce 1.4s infinite;
margin: 0 2px;
}
.dots span:nth-child(2) { animation-delay: 0.2s; }
.dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-6px); }
}
</style>
6.3 输入组件——文本 + 图片 + 快捷问题
Why:快捷问题推荐能引导用户提问,减少"不知道怎么问"的尴尬,同时提高 AI 命中知识库的概率。
<!-- ChatInput.vue -->
<template>
<div class="chat-input-area">
<!-- 快捷问题推荐 -->
<div v-if="showQuickQuestions" class="quick-questions">
<span class="label">猜你想问:</span>
<el-tag
v-for="q in quickQuestions"
:key="q"
effect="plain"
class="quick-tag"
@click="selectQuickQuestion(q)"
>
{
{ q }}
</el-tag>
</div>
<!-- 输入区域 -->
<div class="input-row">
<!-- 图片上传 -->
<el-upload
:show-file-list="false"
:before-upload="handleImageUpload"
accept="image/*"
>
<el-button :icon="Picture" circle />
</el-upload>
<!-- 文本输入 -->
<el-input
v-model="inputText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="输入您的问题..."
@keydown.enter.exact.prevent="handleSend"
/>
<!-- 发送按钮 -->
<el-button
type="primary"
:icon="Promotion"
circle
:disabled="!inputText.trim() || disabled"
@click="handleSend"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Picture, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps<{
disabled?: boolean
quickQuestions?: string[]
}>()
const emit = defineEmits<{
send: [text: string, images?: string[]]
}>()
const inputText = ref('')
const uploadedImages = ref<string[]>([])
const showQuickQuestions = computed(() =>
props.quickQuestions && props.quickQuestions.length > 0
)
const handleSend = () => {
const text = inputText.value.trim()
if (!text) return
emit('send', text, uploadedImages.value)
inputText.value = ''
uploadedImages.value = []
}
const selectQuickQuestion = (q: string) => {
emit('send', q)
}
const handleImageUpload = (file: File) => {
if (file.size > 5 * 1024 * 1024) {
ElMessage.warning('图片大小不能超过5MB')
return false
}
// 上传到OSS获取URL
uploadToOss(file).then(url => {
uploadedImages.value.push(url)
})
return false
}
</script>
<style scoped>
.chat-input-area {
border-top: 1px solid #e4e7ed;
padding: 12px 16px;
}
.quick-questions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.quick-questions .label {
font-size: 13px;
color: #909399;
}
.quick-tag {
cursor: pointer;
transition: all 0.2s;
}
.quick-tag:hover {
color: #409eff;
border-color: #409eff;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.input-row .el-input {
flex: 1;
}
</style>
6.4 历史会话列表
Why:用户需要回顾之前的对话,比如之前问过的操作步骤、之前的退款进度。左侧面板是标准的 IM 交互模式,用户天然习惯。
<!-- SessionList.vue -->
<template>
<div class="session-list">
<div class="session-header">
<el-input
v-model="searchText"
placeholder="搜索历史会话"
prefix-icon="Search"
clearable
size="small"
/>
</div>
<div class="session-items">
<div
v-for="session in filteredSessions"
:key="session.sessionId"
:class="['session-item', { active: session.sessionId === activeSessionId }]"
@click="$emit('select', session.sessionId)"
>
<div class="session-info">
<div class="session-title">{
{ session.lastMessage || '新会话' }}</div>
<div class="session-meta">
<span class="time">{
{ formatTime(session.updatedAt) }}</span>
<el-tag
v-if="session.status === 'TRANSFERRED'"
type="warning"
size="small"
>
已转人工
</el-tag>
</div>
</div>
<el-button
:icon="Delete"
circle
size="small"
text
@click.stop="handleDelete(session.sessionId)"
/>
</div>
<el-empty v-if="filteredSessions.length === 0" description="暂无会话记录" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import type { ChatSession } from '@/types/chat'
const props = defineProps<{
sessions: ChatSession[]
activeSessionId?: string
}>()
const emit = defineEmits<{
select: [sessionId: string]
delete: [sessionId: string]
}>()
const searchText = ref('')
const filteredSessions = computed(() => {
if (!searchText.value) return props.sessions
return props.sessions.filter(s =>
s.lastMessage?.includes(searchText.value)
)
})
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
const handleDelete = (sessionId: string) => {
emit('delete', sessionId)
}
</script>
<style scoped>
.session-list {
width: 280px;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
background: #fafafa;
}
.session-header {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
}
.session-items {
flex: 1;
overflow-y: auto;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.session-item:hover {
background: #ecf5ff;
}
.session-item.active {
background: #d9ecff;
}
.session-title {
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.session-meta {
display: flex;
gap: 8px;
margin-top: 4px;
}
.time {
font-size: 12px;
color: #909399;
}
</style>
6.5 管理后台——统计看板
Why:管理后台让运营团队自助管理知识库、查看统计数据,而不是每次都找开发。
<!-- AdminDashboard.vue -->
<template>
<div class="admin-dashboard">
<el-row :gutter="16" class="stat-cards">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="今日对话数" :value="stats.todayChats" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="AI解决率" :value="stats.aiSolveRate" suffix="%" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="平均满意度" :value="stats.avgRating" suffix="星" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="转人工率" :value="stats.transferRate" suffix="%" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<!-- 热门问题TOP10 -->
<el-col :span="12">
<el-card header="热门问题 TOP10">
<el-table :data="stats.hotTopics" stripe size="small">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="question" label="问题" show-overflow-tooltip />
<el-table-column prop="count" label="频次" width="80" />
<el-table-column prop="resolvedRate" label="解决率" width="80">
<template #default="{ row }">
<el-progress
:percentage="row.resolvedRate"
:color="row.resolvedRate > 80 ? '#67c23a' : '#e6a23c'"
:stroke-width="6"
/>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- 转人工原因分布 -->
<el-col :span="12">
<el-card header="转人工原因分布">
<div ref="chartRef" style="height: 320px"></div>
</el-card>
</el-col>
</el-row>
<!-- 知识库管理入口 -->
<el-card header="知识库管理" style="margin-top: 16px">
<el-table :data="knowledgeDocs" stripe>
<el-table-column prop="title" label="文档名称" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="chunkCount" label="分片数" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'READY' ? 'success' : 'warning'" size="small">
{
{ row.status === 'READY' ? '已就绪' : '解析中' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button size="small" @click="refreshDoc(row.docId)">更新</el-button>
<el-button size="small" type="danger" @click="deleteDoc(row.docId)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-upload
:show-file-list="false"
:before-upload="handleDocUpload"
accept=".pdf,.docx,.md,.html,.txt"
>
<el-button type="primary" style="margin-top: 12px">上传文档</el-button>
</el-upload>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import { getDashboard, uploadDocument, refreshDocument, deleteDocument } from '@/api/admin'
const stats = ref({
todayChats: 0,
aiSolveRate: 0,
avgRating: 0,
transferRate: 0,
hotTopics: [],
transferReasons: []
})
const knowledgeDocs = ref([])
const chartRef = ref<HTMLElement>()
onMounted(async () => {
stats.value = await getDashboard()
renderTransferChart()
})
const renderTransferChart = () => {
const chart = echarts.init(chartRef.value!)
chart.setOption({
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: stats.value.transferReasons.map(r => ({
name: r.reason,
value: r.count
})),
label: { formatter: '{b}: {d}%' }
}]
})
}
const handleDocUpload = (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('category', 'default')
uploadDocument(formData).then(() => {
// 刷新列表
})
return false
}
const refreshDoc = (docId: string) => refreshDocument(docId)
const deleteDoc = (docId: string) => deleteDocument(docId)
</script>
7. 进阶能力
7.1 多模型路由
不同问题用不同模型,平衡效果和成本:
@Component
public class ModelRouter {
/**
* 根据问题特征路由到不同模型
*/
public String selectModel(String query, List<Message> context) {
// 简单FAQ → Qwen-Turbo(最快最便宜)
if (isSimpleFaq(query)) {
return "qwen-turbo";
}
// 技术支持/复杂推理 → Qwen-Max(最强推理)
if (isComplexQuery(query, context)) {
return "qwen-max";
}
// 默认 → Qwen-Plus(平衡)
return "qwen-plus";
}
private boolean isSimpleFaq(String query) {
// 匹配高频FAQ关键词
Set<String> faqKeywords = Set.of("密码", "重置", "账单", "怎么开通", "如何升级");
return faqKeywords.stream().anyMatch(query::contains);
}
private boolean isComplexQuery(String query, List<Message> context) {
// 上下文超过5轮且仍未解决,升级到强模型
if (context.size() > 10) return true;
// 技术类关键词
Set<String> techKeywords = Set.of("架构", "部署", "接口", "报错", "调试");
return techKeywords.stream().anyMatch(query::contains);
}
}
7.2 情绪检测
识别用户情绪,及时干预:
@Component
public class EmotionDetector {
private static final Set<String> NEGATIVE_WORDS = Set.of(
"投诉", "不满", "垃圾", "骗人", "退钱", "差评", "受不了", "什么破"
);
private static final Set<String> URGENT_WORDS = Set.of(
"急", "紧急", "马上", "立刻", "赶紧", "催"
);
public EmotionResult detect(String message) {
int score = 0; // 负面分,越高越差
for (String word : NEGATIVE_WORDS) {
if (message.contains(word)) score += 2;
}
for (String word : URGENT_WORDS) {
if (message.contains(word)) score += 1;
}
// 连续感叹号
if (message.contains("!!") || message.contains("!!")) score += 1;
if (score >= 4) return EmotionResult.ANGRY;
if (score >= 2) return EmotionResult.UNHAPPY;
return EmotionResult.NEUTRAL;
}
}
7.3 多语言支持
百炼的 Qwen 模型天然支持中英日韩多语言,只需在 Prompt 中指定:
你是一个多语言客服助手,请使用用户使用的语言回复。
如果用户用中文提问,用中文回答;用英文提问,用英文回答。
7.4 语音客服扩展
语音客服的架构在文本客服基础上增加语音处理层:

阿里云百炼集成了 Paraformer(语音识别)和 CosyVoice(语音合成),可以快速扩展语音客服能力。
8. 量化对比:纯人工 vs AI + 人工
| 维度 | 纯人工客服 | AI + 人工混合 | 说明 |
|---|---|---|---|
| 首次响应时间 | 3-5分钟 | 1-3秒 | AI即时响应,提升99%+ |
| 服务覆盖时间 | 8×5 | 7×24 | AI填补夜间和节假日空白 |
| 单客成本 | 15-20元/次 | 3-5元/次 | AI处理70%,成本降低75% |
| 重复问题处理 | 人工重复回答 | AI自动回答 | 60%重复问题归零 |
| 回答一致性 | 因人而异 | 知识库统一标准 | 消除"不同客服不同说法" |
| 高峰承载 | 线性扩人 | AI弹性扩展 | 大促期间无需紧急招人 |
9. 踩坑实录
坑1:流式输出 SSE 断连
现象:对话进行到一半,SSE 连接突然断开,前端显示"服务不可用"。
原因:Nginx 默认 proxy_read_timeout 60秒,AI 生成长回答时容易超时。
解决:
# nginx.conf — SSE 专用 location 配置
location /api/chat/stream {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # 关闭缓冲,实时推送
proxy_cache off; # 关闭缓存
proxy_read_timeout 300s; # 超时5分钟
proxy_send_timeout 300s;
chunked_transfer_encoding on;
}
坑2:知识库回答幻觉
现象:AI 回答了知识库中不存在的信息,比如编造了一个"企业版 Pro 套餐"。
原因:Prompt 约束不够强 + 温度参数过高。
解决:三层防护:
- Prompt 严格约束"只使用参考资料中的信息"
- 温度降到 0.1,减少随机性
- 后处理检测:用另一个轻量模型判断回答是否与知识库片段一致
/**
* 幻觉检测:对比AI回答与知识库片段的一致性
*/
public boolean hasHallucination(String aiResponse, List<KnowledgeChunk> refs) {
String refText = refs.stream()
.map(KnowledgeChunk::getContent)
.collect(Collectors.joining("\n"));
Prompt checkPrompt = new Prompt(List.of(
new SystemMessage("判断AI回答是否完全基于参考资料。只回答YES或NO。"),
new UserMessage("参考资料:\n" + refText + "\n\nAI回答:\n" + aiResponse)
), ChatOptionsBuilder.builder().withModel("qwen-turbo").withTemperature(0.0).build());
String result = chatModel.call(checkPrompt).getResult().getOutput().getText().trim();
return "NO".equalsIgnoreCase(result);
}
坑3:多轮上下文超长 Token
现象:对话到第8轮后,Token 消耗暴增,API 报错"token exceeds limit"。
原因:每轮对话都把完整历史发给模型,10轮后上下文远超模型限制。
解决:滑动窗口 + 摘要压缩:
/**
* 上下文压缩:超过阈值时用摘要替代早期对话
*/
public List<Message> compressContext(List<Message> history) {
// 保留最近5轮原文
int keepRounds = 5;
List<Message> recent = history.subList(
Math.max(0, history.size() - keepRounds * 2),
history.size()
);
if (history.size() <= keepRounds * 2) {
return history;
}
// 早期对话生成摘要
List<Message> early = history.subList(0, history.size() - keepRounds * 2);
String summary = generateSummary(early);
// 组装:摘要 + 最近原文
List<Message> compressed = new ArrayList<>();
compressed.add(new SystemMessage("以下是之前对话的摘要:\n" + summary));
compressed.addAll(recent);
return compressed;
}
坑4:Markdown XSS 风险
现象:如果用户在消息中输入 <script>alert('xss')</script>,前端 Markdown 渲染后可能执行恶意脚本。
原因:markdown-it 默认允许 HTML 标签。
解决:渲染层关闭 HTML + 后端内容过滤:
// 前端:markdown-it 配置 html: false
const md = new MarkdownIt({
html: false, // 禁止HTML标签
linkify: true,
highlight(str, lang) {
/* ... */ }
})
// 后端:输入过滤
public String sanitizeInput(String input) {
// 移除HTML标签
return input.replaceAll("<[^>]*>", "")
.replaceAll("javascript:", "")
.trim();
}
坑5:人工转接会话丢失
现象:用户转接人工后,人工客服看不到 AI 阶段的对话历史。
原因:转接时只传递了会话ID,人工客服的前端没有主动加载历史消息。
解决:转接时自动推送会话摘要 + 历史消息:
// 转接时推送完整上下文给人工客服
TransferPayload payload = TransferPayload.builder()
.sessionId(sessionId)
.summary(summary) // AI生成的摘要
.history(recentMessages) // 最近5轮原文
.transferReason(reason)
.build();
messagingTemplate.convertAndSendToUser(
agent.getAgentId(),
"/queue/transfer",
payload
);
// 人工客服前端:接收到转接通知后自动展示历史
stompClient.subscribe('/user/queue/transfer', (message) => {
const payload = JSON.parse(message.body)
// 展示摘要
showTransferSummary(payload.summary)
// 加载历史消息
loadHistoryMessages(payload.history)
})
10. 最佳实践
AI 客服方案选型决策树

上线检查清单
## AI 客服上线前必查
### 知识库
- [ ] 核心FAQ覆盖率 > 90%(统计TOP100问题)
- [ ] 知识库文档已全部解析成功(无 PARSING 状态)
- [ ] 分片策略按文档类型配置(非一刀切)
- [ ] 定期更新机制已建立(至少每周巡检一次)
### 对话质量
- [ ] Prompt 中禁止编造信息的约束已验证
- [ ] 温度参数 ≤ 0.1
- [ ] 幻觉检测机制已上线
- [ ] 10轮以上对话的上下文压缩已实现
### 安全
- [ ] API-KEY 从环境变量读取,未硬编码
- [ ] SSE 接口已配置 Nginx 超时和缓冲
- [ ] Markdown 渲染已关闭 HTML(防XSS)
- [ ] 输入内容已做过滤(防注入)
### 人工转接
- [ ] 转接触发条件已测试(4种场景)
- [ ] 转接后历史消息能正常展示
- [ ] 人工客服离线时有兜底提示
- [ ] 转接通知实时性 < 3秒
### 监控
- [ ] AI 解决率监控已接入
- [ ] 满意度评价已上线
- [ ] 热门未解决问题每周分析
- [ ] Token 消耗和成本告警已配置
📜 真实性声明
本文所有内容均基于作者在 SaaS 企业客服系统升级项目中的真实经验。场景描述、痛点分析、架构设计、代码实现和踩坑案例均来自实际开发过程。为保护商业机密,部分敏感信息已做脱敏处理,但技术细节保持完整和真实。
如有任何疑问,欢迎在评论区交流讨论。
來源:阿里雲開發者社區 AI · developer.aliyun.com