Vue3 + Spring Boot + 百炼:AI 客服对话系统的全栈实战

简介: 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 客服系统效率对比

指标 改造前 改造后 提升幅度
AI 自主解决率 0% 70% 🆕 新增
平均首次响应时间 5分钟 2秒 ⬇️ 99.3%
夜间服务覆盖率 0% 100% 🆕 全覆盖
客户满意度 72% 93% ⬆️ 29%
人工客服负载 800条/天 240条/天 ⬇️ 70%
重复问题处理量 480条/天 0条 ⬇️ 100%

核心转变:70% 的高频重复问题由 AI 秒级响应,人工客服只处理需要判断力的复杂问题,夜间不再有服务空白。


2. 客服系统的5大痛点

传统客服系统为什么效率低下?根据我的实战经验,核心痛点有五个:

cloud-native_mermaid_1

痛点一:响应慢是最直观的用户体验杀手。高峰期用户排队等30分钟才能连上客服,等待过程中退出率超过40%——用户没等到回答就走了。

痛点二:夜间无服务是很多企业的盲区。10个客服排三班倒成本太高,但不排班就意味着用户夜间问题要等到第二天。对于全球化业务,时区差异让这个问题更加严重。

痛点三:重复问题多是最浪费人力的地方。密码重置、账单查询、套餐说明这类标准化问题,客服闭着眼都能答,但每天还是要机械式重复几百遍,既消耗人力又消磨耐心。


3. AI 客服架构设计

3.1 整体架构

cloud-native_mermaid_2

3.2 核心对话流程

一次完整的 AI 客服对话时序:

cloud-native_mermaid_3

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 语音客服扩展

语音客服的架构在文本客服基础上增加语音处理层:

cloud-native_mermaid_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 约束不够强 + 温度参数过高。

解决:三层防护:

  1. Prompt 严格约束"只使用参考资料中的信息"
  2. 温度降到 0.1,减少随机性
  3. 后处理检测:用另一个轻量模型判断回答是否与知识库片段一致
/**
 * 幻觉检测:对比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 客服方案选型决策树

cloud-native_mermaid_5

上线检查清单

## AI 客服上线前必查

### 知识库
- [ ] 核心FAQ覆盖率 > 90%(统计TOP100问题)
- [ ] 知识库文档已全部解析成功(无 PARSING 状态)
- [ ] 分片策略按文档类型配置(非一刀切)
- [ ] 定期更新机制已建立(至少每周巡检一次)

### 对话质量
- [ ] Prompt 中禁止编造信息的约束已验证
- [ ] 温度参数 ≤ 0.1
- [ ] 幻觉检测机制已上线
- [ ] 10轮以上对话的上下文压缩已实现

### 安全
- [ ] API-KEY 从环境变量读取,未硬编码
- [ ] SSE 接口已配置 Nginx 超时和缓冲
- [ ] Markdown 渲染已关闭 HTML(防XSS)
- [ ] 输入内容已做过滤(防注入)

### 人工转接
- [ ] 转接触发条件已测试(4种场景)
- [ ] 转接后历史消息能正常展示
- [ ] 人工客服离线时有兜底提示
- [ ] 转接通知实时性 < 3秒

### 监控
- [ ] AI 解决率监控已接入
- [ ] 满意度评价已上线
- [ ] 热门未解决问题每周分析
- [ ] Token 消耗和成本告警已配置

📜 真实性声明

本文所有内容均基于作者在 SaaS 企业客服系统升级项目中的真实经验。场景描述、痛点分析、架构设计、代码实现和踩坑案例均来自实际开发过程。为保护商业机密,部分敏感信息已做脱敏处理,但技术细节保持完整和真实。

如有任何疑问,欢迎在评论区交流讨论。

相关文章
|
4天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
|
云安全 安全 网络安全
|
4天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
808 1
|
17天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2933 49
|
11天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2097 2
|
5天前
|
安全 算法 定位技术
《高性价比人生指南》完整原版:普通人用最少的资源,过好这一生的底层逻辑~How To Live Better
📖《高性价比人生指南》是一份基于实证、拒绝焦虑的生存手册,倡导以“人生时薪”为视角,重新计算时间、金钱与精力的成本与收益。它不承诺逆袭,只教人算清账:省下的钱若耗掉时间便亏;买体验比买物品更值;睡眠是最高杠杆的健康投资;关系贵在情绪价值而非表面热络;学习重在改变行为而非自我安慰。核心理念:用低成本、高回报的微小行动,换取长期的选择权——不是成为谁,而是掌控自己人生的主动权。
|
9天前
|
人工智能 JSON Linux
【保姆级】ComfyUI部署教程(下载+安装+配置+工作流,2026最新)
ComfyUI 是一款开源节点式AI生成工具,支持图像、视频、音频及3D内容创作。通过拖拽连线组合模型加载、提示词、采样等节点,构建可复用、易修改的工作流,兼容SD1.5、SDXL、Flux、Qwen Image等主流模型。
|
4天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)