Java 程序员的 AI 进化论 | Spring Boot 搭企业智能客服,从设计到上线
上个月运营那边找我,说人工客服快顶不住了。8 个人的组,每天收 600 多条工单,七成是「订单怎么查」「发票能不能开」「退货流程是什么」这类翻来覆去的问题。领导拍板要搭一套智能客服,先顶住常见问题,复杂的再转人工。
我花了三周从零搭了一版,跑了一周,日均自动解决率能到 58%,剩下转人工。这篇把我怎么设计、怎么实现、踩了哪些坑都写出来,给同样要做这块的兄弟省点时间。
一、整体设计与架构选型
动手前我先扒了三天现有工单数据,把高频问题归了类。不归不知道,一归类发现 70% 的问题其实集中在 8 个意图上。
| 问题类型 | 占比 | 是否适合 AI 接 | 处理方式 |
|---|---|---|---|
| 订单状态查询 | 22% | 是 | 调订单接口直接答 |
| 发票问题 | 14% | 是 | 知识库检索 |
| 退换货流程 | 13% | 是 | 知识库检索 |
| 优惠券使用 | 9% | 是 | 知识库检索 |
| 物流咨询 | 8% | 是 | 调物流接口 |
| 账号异常 | 6% | 部分 | 知识库+转人工 |
| 投诉与纠纷 | 18% | 否 | 直接转人工 |
| 其他长尾 | 10% | 否 | 转人工 |
定下思路:意图识别分流 + 知识库检索兜底 + 命中不了就转人工。不追求 AI 全包,只把那 70% 重复劳动吃掉。
架构上几个核心模块我心里有数了:对话引擎管多轮上下文,知识库管检索,转接策略管何时给人,质量监控管事后复盘。技术栈方面我做了个对比,给你们参考:
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| 纯大模型直答 | 实现快 | 幻觉严重、无业务数据 | 否,不敢上线 |
| 大模型+RAG | 能控范围、可溯源 | 检索质量依赖切分 | 采用 |
| 传统意图机器人 | 可控、便宜 | 维护成本高、不灵活 | 与RAG结合做分流 |
| 接第三方客服SaaS | 省事 | 数据出境、定制弱 | 否,公司不让 |
我选的是意图分类(轻量规则+模型兜底)+ RAG 检索 + 大模型生成的组合。底层 Spring Boot 3.2 + Java 17,对话上下文存 Redis,知识库用 PgVector 做向量检索。
整体流程我画了张图,一眼能看明白从用户提问到回答或转人工的链路:

二、对话引擎:多轮上下文怎么管
这块是整个系统的心脏。用户问「订单 12345 啥时候到」,紧接着问「那能改地址吗」,第二句里的「那」指的是上一句的订单。AI 要能接住这种上下文。
我的做法是每个会话维护一个 Session,对话历史存 Redis,给大模型的时候只取最近 4 轮(8 条消息),再多就截断。理由很简单------上下文塞太长,一是贵,二是大模型会被无关历史带偏。
java
package com.example.chatbot.engine;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@Component
public class ChatSessionManager {
private static final int MAX_ROUNDS = 4; // 只保留最近4轮,控制成本和噪音
private final StringRedisTemplate redis;
private final ObjectMapper mapper = new ObjectMapper();
public ChatSessionManager(StringRedisTemplate redis) {
this.redis = redis;
}
// 追加一条消息并裁剪历史,超长自动丢弃最早的消息
public void appendMessage(String sessionId, String role, String content) {
String key = "chat:session:" + sessionId;
try {
ChatMessage msg = new ChatMessage(role, content);
redis.opsForList().rightPush(key, mapper.writeValueAsString(msg));
redis.opsForList().trim(key, -MAX_ROUNDS * 2, -1); // 只留最近4轮
redis.expire(key, Duration.ofMinutes(30)); // 30分钟无活动自动过期
} catch (Exception e) {
throw new RuntimeException("保存会话历史失败", e);
}
}
public List<ChatMessage> getHistory(String sessionId) {
String key = "chat:session:" + sessionId;
List<String> raw = redis.opsForList().range(key, 0, -1);
List<ChatMessage> history = new ArrayList<>();
if (raw == null) return history;
for (String s : raw) {
try {
history.add(mapper.readValue(s, ChatMessage.class));
} catch (Exception ignored) { }
}
return history;
}
// 标记上一轮是否未命中知识库,用于"连续两轮未命中转人工"判断
public void markMissed(String sessionId, boolean missed) {
redis.opsForValue().set("chat:missed:" + sessionId,
String.valueOf(missed), Duration.ofMinutes(30));
}
public boolean wasLastMissed(String sessionId) {
return "true".equals(redis.opsForValue().get("chat:missed:" + sessionId));
}
public record ChatMessage(String role, String content) { }
}
这里我踩了第一个坑。第一版我没设截断,把整个会话历史全喂给大模型,有个用户聊了 40 多轮,单次请求 token 直接飙到 1.2 万,接口延迟从 2 秒涨到 9 秒,还偶发超时。更要命的是大模型被早期无关对话干扰,开始答非所问。
加了 trim 只留最近 4 轮后,单次 token 稳定在 1500 以内,延迟回到 2 秒出头。多轮对话不是上下文越多越好,够用就行,多了是负担。
三、知识库检索与回答生成
意图分完流,能查接口的直接查接口(比如订单状态调订单服务),查不了的走 RAG。RAG 这套我之前写过一篇进阶优化的,这里只讲在客服场景里最关键的一环------检索不到时怎么办。
很多 RAG 教程默认「总能检索到」,实际线上知识库命中率没那么高。我的做法是设一个相似度阈值,低于阈值的不硬答,直接转人工。宁可少答,不能瞎编。
java
package com.example.chatbot.rag;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class KnowledgeQAService {
private static final double SIM_THRESHOLD = 0.72; // 低于这个分不答,转人工
private final VectorSearchService vectorSearch;
private final LlmClient llm;
public KnowledgeQAService(VectorSearchService vectorSearch, LlmClient llm) {
this.vectorSearch = vectorSearch;
this.llm = llm;
}
public QAResult answer(String question, List<ChatMessage> history) {
List<DocChunk> hits = vectorSearch.search(question, 5);
if (hits.isEmpty() || hits.get(0).score() < SIM_THRESHOLD) {
// 检索质量不够,不要硬编,交给人工
return QAResult.needHuman("知识库未命中,相似度不足阈值");
}
String context = buildContext(hits);
String prompt = """
你是企业客服助手,只能依据下面提供的资料回答用户问题。
资料里没有的内容,直接回复"这个问题我需要转给人工客服"。
资料:%s
用户问题:%s
""".formatted(context, question);
String answer = llm.chat(prompt, history);
return QAResult.ok(answer, hits.get(0).score());
}
private String buildContext(List<DocChunk> hits) {
StringBuilder sb = new StringBuilder();
for (DocChunk d : hits) {
sb.append("- ").append(d.title()).append(":").append(d.text()).append("\n");
}
return sb.toString();
}
public record DocChunk(String title, String text, double score) { }
public record QAResult(boolean ok, boolean needHuman, String content, double score) {
static QAResult ok(String content, double score) {
return new QAResult(true, false, content, score);
}
static QAResult needHuman(String reason) {
return new QAResult(false, true, reason, 0);
}
}
}
第二个坑就在这。第一版我没设阈值,想着让大模型自己判断有没有资料。结果用户问「你们支持比特币支付吗」,知识库里压根没这条,大模型硬是编了一句「支持,请咨询客服开通」,运营差点炸锅。
大模型在没有依据时会"合理地编",这是它最危险的特点。 加了阈值 + Prompt 里明确「没有就转人工」之后,这种幻觉基本没了。阈值我调过几次,0.72 是我用 200 条标注问题测出来的平衡点:
| 相似度阈值 | 命中率 | 幻觉率 | 转人工率 | 我的评价 |
|---|---|---|---|---|
| 0.60 | 82% | 11% | 7% | 幻觉太多,不敢用 |
| 0.72 | 68% | 2% | 23% | 平衡点,采用 |
| 0.80 | 51% | 0.5% | 41% | 太保守,转人工太多 |
四、人工转接与质量监控
转人工不是失败,是设计的一部分。关键是什么时候转、转给谁。
我定的转接规则有三条:检索连续两轮低于阈值、用户明确要求人工、对话里出现负面情绪词。第三条我用了简单关键词匹配,不做复杂情感分析,够用。
java
package com.example.chatbot.router;
import org.springframework.stereotype.Service;
import com.example.chatbot.engine.ChatSessionManager;
@Service
public class HumanHandoffPolicy {
// 负面情绪关键词,命中即转人工(简单粗暴但有效)
private static final String[] NEGATIVE_WORDS = {
"投诉", "差评", "骗子", "退款不退", "你们怎么回事", "气死", "态度差"
};
private final ChatSessionManager sessionManager;
public HumanHandoffPolicy(ChatSessionManager sessionManager) {
this.sessionManager = sessionManager;
}
public boolean shouldHandoff(String sessionId, String userText, boolean thisRoundMissed) {
// 规则1:连续两轮未命中知识库
if (thisRoundMissed && sessionManager.wasLastMissed(sessionId)) {
return true;
}
// 规则2:用户主动要人工
if (userText.contains("人工") || userText.contains("真人")) {
return true;
}
// 规则3:负面情绪
for (String w : NEGATIVE_WORDS) {
if (userText.contains(w)) return true;
}
return false;
}
}
质量监控这块我做得轻,但必须有。每天抽样 5% 的对话人工复核,统计 AI 回答的准确率和满意度。没监控的 AI 客服上线就是裸奔,出了问题你都不知道。
上线一周后的数据我拉了张表:
| 指标 | 上线前(纯人工) | 上线一周后 | 变化 |
|---|---|---|---|
| 日均工单量 | 612 | 598 | -2% |
| 人工处理量 | 612 | 251 | -59% |
| AI 自动解决率 | 0% | 58% | +58% |
| 平均响应时长 | 4分20秒 | 3秒 | -99% |
| 首次解决率 | 71% | 76% | +5% |
| 用户满意度 | 82分 | 79分 | -3分 |
注意看末尾那行,满意度掉了 3 分。我去翻了抽样记录,发现是 AI 回答虽然快,但有些用户觉得「跟机器人聊不如跟人聊」,体验上打折。这是预料中的,后续我在 AI 回复末尾加了「没解决可回复转人工」的提示,满意度回升到 81 分。
五、踩坑补充与几点提醒
除了前面说的上下文截断和幻觉两个坑,再补几个:
坑三:大模型把内部工单号当订单号查。 用户说「我的单号是 2026081300012」,这是工单号不是订单号,AI 直接拿去查订单接口报空。后来我在意图识别里加了号段规则------订单号 12 位纯数字、工单号 13 位带日期前缀,分开了才不乱。
坑四:高峰期接口限流。 大模型 API 有并发限制,促销日咨询量翻三倍,排队超时用户等不及直接挂了。我加了本地排队 + 超时降级,超过 8 秒没返回就先回一句「正在为您查询,稍等」并转人工,体验好很多。
还有一点想强调:智能客服不是用来替代人工的,是用来过滤噪音的。 把 70% 的重复问题交给 AI,让人工腾出手处理真正需要判断的 30%,这才是正确姿势。指望 AI 全包,到头来一定翻车。
六、总结
这套系统我跑了一周多,最直观的感受是------AI 客服的价值不在于多聪明,而在于把重复劳动自动化,让人工做更有价值的事。搭的时候盯住三个点:检索有没有命中、没命中别硬编、转人工要果断。
给你一份落地检查清单,照着走少踩坑:
| 检查项 | 建议 |
|---|---|
| 对话上下文 | 只留最近 4 轮,超过截断,别全量喂模型 |
| 检索阈值 | 用标注数据测出平衡点,0.7 附近起步 |
| 幻觉防控 | Prompt 明确"无依据转人工",别让模型自己编 |
| 转人工规则 | 连续未命中+主动要求+负面情绪,三选一即转 |
| 号码识别 | 区分订单号/工单号/物流单号,别混查 |
| 高峰降级 | 设超时阈值,超时先安抚再转人工 |
| 质量监控 | 每天抽样 5% 复核,没有监控等于裸奔 |
| 满意度兜底 | AI 回复末尾留转人工入口,别堵死 |
有任何问题评论区聊,能答的我都答。