去年双 11 预售开跑前三小时,财务总监拎着一杯美式晃晃悠悠晃到我工位旁,指着上个月大模型 API 的账单说:“李工,这个月大促如果按照你们现在的调用量,客服这块预计得烧掉近百万的 Token 费用。这还没算网关和向量数据库的扩容成本,咱账上可禁不起这么造啊。”
回到座位上,我拉出了去年整个大促期间智能客服的几千万条历史会话日志。拉完日志聚类和分词热力图之后,事实直接甩了我们一巴掌:
在浩如烟海的客服提问中,真正需要大模型进行多轮复杂推理、逻辑拆解的提问占比不到 15%。剩下的 85% 流量,几乎全在疯狂重复那 200 个经典问题:“什么时候发货?”、“运费险怎么退?”、“满 300 减 50 怎么凑单?”、“预售定金能不能退?”、“新疆包邮吗?”。
如果面对“发货时间”这种问题,你的系统还要每次都把长达数千字的店铺规则塞进 Prompt,屁颠屁颠地去调下游大模型推理,那不是高科技,那是纯粹的工程失职和资源浪费。
今天我们聊聊在大促极限并发下,如何基于 80/20 法则,对那高频的 200 个提问做极端的工程化短路与纳秒级拦截。
流量热力图分布与三层过滤拓扑
在大促流量洪峰面前,保护系统的最好方式就是“不让流量穿透到底层”。我们把智能客服的问答链路重构成了三层漏斗:
[ 用户提问请求 ] │ ▼ 【L1: 确定性短路层】──(前缀/精准/正则 Trie 树) ──> [命中] ──> 直接渲染模板 (耗时 < 1ms) │ (未命中) ▼ 【L2: 语义向量缓存层】──(Caffeine + 向量近场检索) ──> [相似度 ≥ 0.95] ──> 返回标准解 (耗时 < 10ms) │ (未命中) ▼ 【L3: 大模型推理兜底】──(Spring AI + Function Calling) ──> 动态生成答复 (耗时 1~3s)通过这套漏斗,我们把涌入客服网关的流量,在 L1 和 L2 层直接吃掉了将近 80%,真正穿透到大模型进行实时推理的,只有那 20% 真正长尾、个性化的复杂咨询。
L1 层:基于 AC 自动机与 Trie 树的极速短路
对于“发货”、“运费险”、“定金”这些高频关键词,以及带有固定句式的提问,绝对不能走任何带 I/O 的外部调用。我们直接使用 Aho-Corasick 算法(AC 自动机)在应用内存中维护一个双数组字典树。
只要用户输入的文本在分词后命中了高频意图模板,直接短路返回:
package com.yali.customer.filter; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; @Component public class ExtremeFastPathFilter { // 针对固定正则或关键词的静态映射 private static final Map<Pattern, String> FAST_RULES = new HashMap<>(); static { // 规则1:发货时效咨询 FAST_RULES.put( Pattern.compile(".*(什么时候|几号|多久|何时).*(发货|出库|发走).*"), "order_delivery_time_template" ); // 规则2:运费险赔付 FAST_RULES.put( Pattern.compile(".*(有没有|含不含|送不送|退).*(运费险).*"), "shipping_insurance_template" ); // 规则3:定金规则 FAST_RULES.put( Pattern.compile(".*(定金|预付款).*(退|能退|不想要).*"), "deposit_refund_rules_template" ); } public FastPathResult tryFastMatch(String userQuery) { if (userQuery == null || userQuery.isBlank()) { return FastPathResult.miss(); } String cleanQuery = userQuery.trim().toLowerCase(); for (Map.Entry<Pattern, String> entry : FAST_RULES.entrySet()) { if (entry.getKey().matcher(cleanQuery).matches()) { return FastPathResult.hit(entry.getValue()); } } return FastPathResult.miss(); } public record FastPathResult(boolean isHit, String templateId) { public static FastPathResult hit(String templateId) { return new FastPathResult(true, templateId); } public static FastPathResult miss() { return new FastPathResult(false, null); } } }L2 层:本地内存向量哈希与语义缓存判定
很多用户的提问虽然意思完全一样,但表述千奇百怪。比如“你们家的快递一般几天能送到家?”和“从付款到签收大概要多久?”,传统的字符串匹配或正则会全部漏掉。
这时候就需要用到语义缓存。很多刚接触向量检索的同学,喜欢把用户的每个提问都发远程请求去调用 Embedding 模型,再通过 gRPC 查远程 Milvus。在大促峰值每秒几万 QPS 的场景下,这种搞法会把 Milvus 和 Embedding 服务的带宽直接打穿。
我们的策略是:将高频 200 个核心问题的标准问法及其 Embedding 向量,全量固化在每个 Java 进程的 Caffeine 本地内存中。
package com.yali.customer.cache; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import jakarta.annotation.PostConstruct; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @Service public class LocalSemanticCacheService { private final EmbeddingModel embeddingModel; // 内存中维护的高频意图列表 private final List<StandardIntent> cachedIntents = new ArrayList<>(); // 相似度截断阈值,必须设得足够严格,避免答非所问 private static final float SIMILARITY_THRESHOLD = 0.93f; public LocalSemanticCacheService(EmbeddingModel embeddingModel) { this.embeddingModel = embeddingModel; } @PostConstruct public void initTop200Intents() { // 在应用启动阶段,预热 200 个高频问题的标准向量 registerIntent("delivery_time", "下单后大概多久可以发货,什么时候能到?"); registerIntent("shipping_insurance", "商家有赠送退货运费险吗?怎么理赔?"); registerIntent("price_guarantee", "双11活动期间支持买贵退差价吗?保价多久?"); // 持续加载其余高频问答... } private void registerIntent(String intentCode, String standardQuestion) { float[] vector = embeddingModel.embed(standardQuestion); cachedIntents.add(new StandardIntent(intentCode, standardQuestion, vector)); } public StandardIntent matchTopIntent(float[] queryVector) { StandardIntent bestMatch = null; float maxScore = -1.0f; for (StandardIntent intent : cachedIntents) { float score = cosineSimilarity(queryVector, intent.vector()); if (score > maxScore) { maxScore = score; bestMatch = intent; } } if (maxScore >= SIMILARITY_THRESHOLD) { return bestMatch; } return null; } // 纯纯的内存点积计算,单次对比耗时微秒级 private float cosineSimilarity(float[] vectorA, float[] vectorB) { float dotProduct = 0.0f; float normA = 0.0f; float normB = 0.0f; for (int i = 0; i < vectorA.length; i++) { dotProduct += vectorA[i] * vectorB[i]; normA += vectorA[i] * vectorA[i]; normB += vectorB[i] * vectorB[i]; } return dotProduct / ((float) Math.sqrt(normA) * (float) Math.sqrt(normB)); } public record StandardIntent(String intentCode, String standardText, float[] vector) {} }动态参数插槽:把冰冷模板变成千人千面
直接返回硬编码的静态文案,用户体验会非常生硬。例如用户询问“我的订单发货了吗?”,即使命中了delivery_time意图,我们也不能只回一句通用的“48小时内发货”。
我们在拦截层后面挂了一个极其轻量的“插槽渲染引擎”:
- 如果用户已经登录且传入了当前上下文的
order_id,直接从近端 Redis 查出该订单的发货状态(例如“已在杭州分拣中心打包完成”)。 - 将状态动态填入模板的占位符中,生成一段兼具准确性与人情味的答复。
- 这一步完全绕过大模型推理,只消耗几毫秒的 Redis 查询,但给用户的感知却是大模型正在实时调取后台数据为其服务。
public String renderResponse(String templateId, Long userId, Long orderId) { if ("order_delivery_time_template".equals(templateId) && orderId != null) { // 从 Redis 读取物流预估缓存 String logisticsStatus = logisticsCacheService.getStatus(orderId); return String.format("尊敬的会员,您的订单【%d】目前状态为【%s】,预计将在明天下午前完成派送。", orderId, logisticsStatus); } return "双11大促期间,实物商品在支付成功后48小时内陆续出库,请您耐心等待。"; }实战指标与压测收益
这套基于 80/20 法则的极端优化方案在全链路压测中交出了一份惊艳的成绩单:
- 调用量削减:穿透到远程大模型的实际推理请求直接减少了 78.4%,大促高峰期的 Token 预算直接节省了数十万。
- P99 延迟压缩:高频提问的端到端响应耗时从原来的平均 1800ms(大模型单次生成耗时),直接降至惊人的 6.5ms。对于用户来说,只要按下发送键,答案几乎与屏幕刷新率同频出现。
- 系统可用性提升:由于绝大部分流量被本地内存与近场缓存拦腰截断,即便下游大模型服务在大促峰值遭遇临时降速或偶发性限流,前台 80% 的用户咨询也完全感受不到任何抖动。
技术架构的魅力往往不取决于你使用了多么前沿复杂的概念,而在于你能否洞察流量背后的真实规律,在成本、体验与稳定性之间,找到那个最优雅的平衡点。