基于Agent的智能客服项目架构优化:从高并发瓶颈到弹性扩展实战
测试环境:
CPU:Intel(R) Xeon(R) Gold 6248R × 32
内存:128 GB
网络:10 Gbps
JDK:17.0.7
压测工具:JMeter 5.6,单机 2000 线程,4 台负载机
1. 业务场景:500 并发下的“雪崩”现场
去年 618 大促,我们接到一线告警:核心智能客服接口平均 RT 从 600 ms 飙升到 4.8 s,超时率 23%,用户连续追问三次后机器人直接“失忆”——上下文丢失。
根因是传统“Tomcat 线程池 + 共享 Map”架构:
- 线程争抢:同步块导致 68% 的 CPU 消耗在锁等待
- 上下文漂移:HashMap 无锁读写在并发 resize 时触发死链,出现 NullPointerException
- 无弹性:固定 200 工作线程,高峰时排队 9 w+ 任务,低谷时空转 80% 资源
一张图胜过千言万语:
2. 技术选型:Actor 模型 vs 线程池
| 指标 | 线程池方案 | Actor 方案(Akka 2.8) |
|---|---|---|
| 峰值 QPS | 4 200 | 12 800 |
| P99 延迟 | 3.1 s | 380 ms |
| CPU 利用率 | 45% 抖动 | 72% 平稳 |
| 内存占用 | 31 GB | 18 GB |
JMeter 压测截图(Actor 组 12k QPS 稳定 plateau):
结论:Actor 的“单线程内部 + 消息驱动”天然避免锁竞争,背压机制(Back-Pressure)在队列满时自动降速,而不是暴力抛异常。
3. 核心实现
3.1 会话状态机(Akka FSM)
public enum State { IDLE, WAIT_SLOT, WAIT_CONFIRM, CLOSED } public enum Event { ASK, REPLY, TIMEOUT, RESET } public class SessionActor extends AbstractFSM<State, SessionData> { { startWith(IDLE, new SessionData()); when(IDLE, match(AskMsg.class, m -> goto(WAIT_SLOT).replying(new Ack())) ); when(WAIT_CONFIRM, match(TimeoutMsg.class, m -> goto(CLOSED).replying(new End())) ); onTransition({ ifState(WAIT_CONFIRM, CLOSED, () -> getContext().system().scheduler().scheduleOnce( Duration.ofMinutes(30), self(), PoisonPill.getInstance(), getContext().dispatcher(), ActorRef.noSender() ) ); }); } }状态转换图(Mermaid):
stateDiagram-v2 [*] --> IDLE IDLE --> WAIT_SLOT: ASK WAIT_SLOT --> WAIT_CONFIRM: REPLY WAIT_CONFIRM --> CLOSED: TIMEOUT CLOSED --> [*]3.2 分布式上下文存储(Redis 分片)
目标:单会话 ≤ 8 KB,集群 20 w 并发写,热点 key 打散。
分片策略:CRC16(sessionId) % 8192 → 物理槽,预分配 128 个 Master,每槽 64 个会话。
public class ShardedContextRepo { private final RedissonClient[] shards; public Mono<String> get(String sessionId){ int slot = Math.abs(sessionId.hashCode() % shards.length); RBucket<String> bucket = shards[slot].getBucket("ctx:" + sessionId); return bucket.reactive().get(); } public Mono<Void> set(String sessionId, String ctx, int ttl){ int slot = Math.abs(sessionId.hashCode() % shards.length); RBucket<String> bucket = shards[slot].getBucket("ctx:" + sessionId); return bucket.reactive().set(ctx, ttl, TimeUnit.SECONDS).then(); } }- 采用 CAS 乐观锁(Redisson
RMapCache.putIfAbsent)解决并发写丢失 - 冷热分层:30 分钟未访问自动降级到 SSD 节点,节省 42% 内存
3.3 动态扩缩容算法
触发条件(每 10 s 采样):
- CPU ≥ 75% 且 连续 3 次或
- 堆内存 ≥ 70% 且 新生代 GC 平均停顿 ≥ 250 ms
代码片段(K8s HPA 外挂):
@Component public class AutoScaler { @Value("${akka.cluster.min-nodes}") private int minNodes; @Value("${akka.cluster.max-nodes}") private int maxNodes; public void evaluate(double cpu, double mem, long gcPause){ if ((cpu > 0.75 && consecutiveCpu.incrementAndGet() > 3) || (mem > 0.7 && gcPause > 250)) { int target = Math.min(currentNodes + 2, maxNodes); scaleTo(target); } else if (cpu < 0.3 && mem < 0.4) { int target = Math.max(currentNodes - 1, minNodes); scaleTo(target); } } }扩容动作:向 K8s 提交PATCH /scale副本数;Akka 节点自动加入集群,ShardCoordinator 重新平衡分片,平均耗时 38 s。
4. 避坑指南
4.1 消息幂等三方案
- 全局唯一 MsgId + Redis SetNX(1 ms,需额外 TTL)
- 业务字段指纹(userId+timestamp+questionMD5)+ MySQL 唯一索引(写入重,适合对账)
- 幂等表 + 滑动窗口(窗口 5 min,重复直接丢弃,最省资源)
生产选用方案 3,窗口存在堆外内存,GC 压力几乎为 0。
4.2 冷启动预热
- 启动脚本提前创建 50% 的 Actor(空分片),防止首次请求创建耗时 60 ms
- 加载热点词向量到本地堆外缓存,减少首次 LLM 调用 200 ms
- 通过 Kubernetes postStart 钩子完成, readinessProbe 不通过前不 Naming Service 注册
4.3 对话超时内存泄漏排查
现象:上线 3 天后老年代使用率线性上涨 5%/h。
定位:
jmap -histo发现TimeoutTimer实例 470 w+- 代码复查:FSM 在
WAIT_CONFIRM状态未取消定时器即关闭 - 修复:在
onTransition(WAIT_CONFIRM, CLOSED)块内显式cancelTimer(key),上线后老年代增长降至 0.2%/h
5. 效果与收益
- 会话吞吐量:4.2 k → 12.8 k QPS(↑3×)
- 平均延迟:3.1 s → 380 ms(↓87%)
- 资源成本:同等峰值节省 42% 内存,缩容后日省 210 核·时
- 全年大促零重大故障,SLA 达到 99.97%
6. 开放思考:当 Agent 遇见 LLM
大模型推理动辄 2~5 s,与“毫秒级”客服体验天然矛盾。可能的平衡路线:
- 分层回复:Agent 本地小模型(1 B 参数)兜底 80% 高频问答;置信度 < 0.85 再调用云端大模型,用户先看到“思考中…”占位
- 流式输出 + 优先级队列:LLM token 边生成边返回,前端逐字渲染,主观等待感降低 40%
- 推理缓存:对同一问题 Embedding 向量余弦 < 0.02 直接复用上次结果,缓存命中率 35%,P99 延迟再降 30%
如何在业务、成本、体验之间继续找最优解,欢迎一起探讨。