1. 先说清楚:为什么 AI 一接入,系统就变得这么“脆”
做 Java 后端这几年,我接入过不少外部能力,但 AI 大模型接口是我遇到过的最特殊的一类依赖。它和普通 RPC 调用有本质区别:单次响应动辄几秒到几十秒,费用按 token 计费,高峰期还经常因为上游限流或者模型负载过高直接抛 5xx。更要命的是,AI 调用往往是串在一个完整业务链路里的——用户提问、内容生成、智能分析、自动测试用例生成、代码 review 辅助,这些场景一旦 AI 接口抖动,整条业务都能被拖垮。
很多团队最初接入 AI 的时候,代码写得很“直白”:Controller 里直接调 OpenAI 或者国内大模型 SDK,同步等待返回。简单场景没问题,但一旦流量上来,问题就成串地冒出来:一个慢调用占住 Tomcat 线程,几百个并发进来线程池直接打满;上游接口限流,系统里没有任何兜底,用户只能看到超时;更隐蔽的是,有些低价值的 AI 任务(比如批量把旧文档做一遍摘要)和高价值的实时问答(用户正等着回复)共用同一个执行通道,低价值任务把高价值的挤到队尾,用户体验直线下降。
这些问题本质上是两件事没做好:请求的优先级没有建立起来,以及对 AI 依赖的保护机制(熔断降级)没有工程化落地。市面上讲 Sentinel、Hystrix 的文章很多,但大多停留在“加个注解”的层面,真正要应对 AI 场景下高延迟、高成本、强抖动这些特性,需要把优先级和熔断降级当做一个整体架构来设计,而不是零散地加过滤器。这篇文章我就基于实际项目里的做法,把整个设计思路、关键代码、参数配置和踩过的坑完整梳理一遍。
适合谁来读?正在把 AI 能力接入 Java 后端的人,尤其是遇到了“AI 一抖、服务就挂”“高价值请求被低价值任务挤占”这类问题的人。不需要你懂太多算法,但需要你有 Spring Boot 基础,知道线程池和基本的设计模式。读完你能拿走一套可直接落地的代码骨架和参数配置参考,不用再从零摸索。
2. 整体架构拆解:AI 场景下“优先级 + 熔断降级”为什么必须一起做
2.1 AI 请求的特殊性决定了保护方案不能照搬普通接口
先看一组我在项目中统计到的数据:普通业务接口 P99 响应时间大约 100ms,超时阈值可以很从容地设在 500ms;而 AI 接口的 P99 轻松超过 5 秒,长文本生成场景甚至可以到 20 秒以上。普通接口超时最多损失一个线程几秒钟,AI 接口超时却可能让上游重试、资源空转、费用白白流失。
这直接推翻了两个常见的做法。第一个是“超时时间随便设一个”:设短了,正常的慢响应被误杀;设长了,线程池被长时间占用的风险急剧上升。第二个是“熔断阈值参考普通 RPC 的标准配置”,比如错误率 50% 就断。AI 模型在上下文变长之后,响应时间可能大幅波动,用 50% 这种一刀切的参数很容易误触熔断——对 AI 接口来说,上游有几率性的高延迟,需要的是根据不同的业务场景配置不同的容忍度。
另一个 AI 场景独有的问题是成本控制与熔断的关系。普通接口熔断是为了节省线程资源和数据库连接,AI 接口熔断还多了一层含义:节省 token 费用。如果上游已经明显异常,继续把用户请求转到 AI 上游,烧的是真金白银。所以 AI 场景的熔断降级不仅要考虑“系统资源受不受影响”,还要考虑“这笔 genAI 调用值不值得花这个钱”。
2.2 两个核心概念:优先级队列与熔断状态机,如何形成合力
在这个项目里,我最终确定了一个两层联动的架构。第一层是入口优先级控制,它解决的是“谁的请求先进去”的问题。AI 任务天然分三六九等:用户实时提问是 P0,AI 辅助代码 review 是 P1,批量数据清洗、文档归档摘要这种后台任务是 P2。如果让所有任务挤在同一个队列里,FIFO(先进先出)的默认策略会把 P2 的长任务排到最前面,导致 P0 的实时请求等待时间不可控。解决办法不是简单改限流规则,而是为执行 AI 调用的线程池定制一个优先级队列,让高优任务永远优先被消费。
第二层是熔断降级,它解决的是“上游出问题时系统怎么办”的问题。AI 上游不是每次都会把服务不可用写在错误码里——有时候是响应极慢,有时候是返回格式不对,有时候是更隐蔽的“部分失败”,比如生成中断但 HTTP 状态码还是 200。所以熔断的触发条件要综合错误率和慢调用比例,同时用一个状态机来管理“关-开-半开”的自动流转,避免频繁抖动导致系统反复在正常和降级之间横跳。
这两层必须配合才能形成闭环:高优先级请求在进入执行阶段之前,先经过熔断器检查。熔断器是开的状态,哪怕你是 P0 请求也不能硬闯上游,只能走降级路径;反过来,熔断器是关的状态,但队列里挤满了 P2 低优任务,P0 请求依然会被卡在队尾,所以优先级控制是独立于熔断保护的第二道闸门。两者一前一后,共同组成 AI 生命线的两道防线。
2.3 为什么选线程池优先级改造,而不是直接在业务代码里 if-else
很多人第一反应是“在业务代码里判断 taskType,然后手动选择不同的线程池去执行”,这不是不行,但不适合工程化。第一,如果有几十个调用点,每个地方都要写一份 if-else 逻辑,维护成本爆炸;第二,不同服务实例对“高优/低优”的定义可能不一致,这种散落在业务代码里的规则很难统一调整。我的做法是抽象一个统一的 AI 执行器,内部封装线程池和优先级队列,对外只暴露一个带 TaskPriority 参数的 submit 接口。业务方不需要知道任务是怎么被排队的,只需要声明自己这个请求属于什么等级。这是工程化的第一原则:定义一个核心的执行骨架,把策略变化收敛到一个可配置的角落。
优先级策略本身也用配置中心管理,而不是写死在代码里。举个例子,运营后台有批量生成商品描述的需求,平时这个场景是 P2 低优,但大促期间运营同事希望它快一点,那我们只需要通过配置把该场景的权重往上调,不用改代码重新发版。这种“运行时动态调整优先级”的能力,只有把优先级抽象成独立维度才能做到。
2.4 失败的适当性设计:每个 AI 调用都必须有降级路径
我给自己定了一条硬性要求:“任何 AI 调用,都不允许因为 AI 上游挂掉而导致主业务失败。”这句话听起来很简单,落地时却很考验设计功力。降级不是单一方案,而应该是一组“从优到劣”的序列,按照成本和对用户体验的影响逐级递进。
以我们项目里的“AI 智能问答”为例,完整的降级阶梯是这样的:最优情况是调用 AI 返回实时结果;如果 AI 超时但有相似问题命中了本地缓存,直接返回缓存答案,虽然可能不是 100% 贴合当前问题,但至少用户不用等;缓存也没有,就调用一个轻量级的本地关键词匹配服务,给出几个候选常见问题;到了这一步还不行,才返回一个友好的静态提示“AI 服务暂时开小差了,请稍后重试”。每降一级,用户体验差一点,但系统不会因为 AI 问题而崩溃。
这个降级序列必须在架构层面提前实现,而不是等故障发生了再临时去写。我发现很多人做熔断降级只做了一半——熔断是做了,降级逻辑没写,结果熔断触发后返回的是一堆不友好的异常堆栈,用户感知极差,还没有任何备选响应来维持业务运行。
3. 核心代码实现:优先级线程池与熔断器的工程化落地
3.1 优先级线程池的两种实现路径与取舍
Java 里给线程池加优先级,最直接的想法是用 PriorityBlockingQueue 代替默认的 LinkedBlockingQueue。这个思路是对的,但有一个大坑差点让我翻车:任务不仅要分优先级,还要区分“同优先级之间的先后顺序”。如果直接用 PriorityBlockingQueue,任务优先级相同的时候,比较器返回 0,队列不保证 FIFO 顺序,就是说同时提交的 10 个同等级任务,它们的执行顺序可能是乱掉的。为保持同优先级公平,正确的做法是给任务增加一个自增序号,比较器先比优先级,优先级相同再比序号。这个细节在面试里也经常被翻出来问,属于典型的八股文实战化案例。
完整实现我贴在这里:
public class PriorityTask implements Runnable, Comparable<PriorityTask> { private final Runnable actualTask; private final TaskPriority priority; private final long sequence; private static final AtomicLong SEQ = new AtomicLong(0); public PriorityTask(Runnable actualTask, TaskPriority priority) { this.actualTask = actualTask; this.priority = priority; this.sequence = SEQ.getAndIncrement(); } @Override public int compareTo(PriorityTask other) { int cmp = Integer.compare(priority.getLevel(), other.priority.getLevel()); return cmp != 0 ? cmp : Long.compare(sequence, other.sequence); } @Override public void run() { actualTask.run(); } }然后是线程池的配置。这里还有个更隐蔽的问题:ThreadPoolExecutor 的 execute() 流程是“先判断 corePoolSize,再放队列,再判断 maxPoolSize”。默认情况下超过核心线程数的任务会被扔进队列排队,可如果队列是无界的,那么 maxPoolSize 永远没有机会被触达。对 AI 任务来说这非常危险——因为每个任务耗时几秒到几十秒,核心线程很快占满,然后任务在无界队列里越攒越多,系统的“虚假排队”会让延迟变得不可接受。
我最终的线程池配置是:核心线程数 = 2,最大线程数 = 8,队列容量 = 200,拒绝策略 = CallerRunsPolicy。核心线程数不设高是因为 AI 调用是高 I/O 等待型任务,线程数太高反而会增加上下文切换开销,同时昂贵的 AI 并发数需要控制成本;使用 CallerRunsPolicy 是因为这个线程池供 AI 调用专用,当任务被拒绝时,由调用方线程同步执行,以此天然实现背压保护,而不是直接把请求扔掉。
public class AiThreadPoolConfig { public static ThreadPoolExecutor createAiExecutor() { return new ThreadPoolExecutor( 2, 8, 60L, TimeUnit.SECONDS, new PriorityBlockingQueue<>(200), new ThreadFactoryBuilder().setNameFormat("ai-executor-%d").build(), new CallerRunsPolicy() ); } }3.2 用隔离的“多级渠道”实现 P0/P1/P2 分治
只靠一个带优先级的线程池能解决部分问题,但有一个场景它处理不了:当 P2 的低优任务特别多的时候,虽然 P0 会先被消费,但是 P0 还是可能被迫排队——因为线程池里所有线程可能都正在执行 P2 的慢任务。优先级只决定了“谁先进入待执行队列”,无法抢占已经被线程占用的资源。
针对这个问题,我在架构上做了进一步的隔离:把执行渠道拆成三个独立的线程池,分别处理 P0、P1、P2,同时让 P0 和 P1 可以临时借用 P2 的空闲线程。近似于物流分拣中心,加急件不仅有专门柜台,普通柜台没活儿时也可以过来帮忙处理。这种设计的代价是会多消耗一些线程资源,但换来的是故障隔离能力——P2 的批量任务要是把线程池占满了,影响范围被限制在 P2 自己的池子,P0 不受任何冲击。
有人会问“为什么不直接全部用主线程池,只靠优先级队列就够了”。优先级队列解决的是排队次序,资源隔离解决的是故障范围,二者缺一不可。在压测里我看过非常典型的现象:只用优先级队列时,当 P2 突发大流量占满了所有 worker 线程,P0 请求依然要等 worker 释放,平均响应时间飙升;改为线程池隔离后,P0 的 P99 从原来的 2500ms 降到了 1300ms,提升非常显著。
3.3 自研轻量熔断器:为什么不用现成框架
说到熔断,绝大多数人第一反应是直接引入 Sentinel 或者 Hystrix。这两个框架本身很好,但落到 AI 调用这个场景,我发现有几个痛点:第一,AI 调用的错误判定标准和普通 RPC 不同,不能只依赖异常或 HTTP 状态码;第二,AI 调用有“慢调用”这个非常重要的信号,但很多框架的慢调用配置是全局的,不方便针对不同模型不同场景单独设置;第三,我需要非常精细地观测每一次 AI 调用的结果和耗时,现成框架对这些指标的暴露粒度不一定满足我的需求。
所以我决定自研一个轻量级的熔断器。核心逻辑是滑动窗口计数器,用定长环形数组存最近 N 个时间窗口内的成功、失败和慢调用次数。先定义三个比例作为熔断条件:
- 错误率:比如最近 1 分钟内错误比例超过 30% 触发熔断
- 慢调用比例:比如最近 1 分钟内慢调用(耗时超过 5 秒)比例超过 30% 触发熔断
- 最小请求数:比如窗口内请求数少于 10 次时不判定,防止小流量下误触发
为什么要加“最小请求数”这个条件?这是我在实战里踩过的坑。刚开始熔断器上线后,低峰期偶尔有 1 次超时就把熔断给触发了,后面 30 秒内所有 AI 请求全部被降级。排查后发现,问题就出在“1 次错误 + 1 次总请求 = 100% 错误率”这组数据上,这个统计在样本量太小的时候毫无意义,所以必须加一个最小请求数的保护。
熔断器状态机的核心代码逻辑如下:
public class AiCircuitBreaker { private final AtomicReference<BreakerState> state = new AtomicReference<>(BreakerState.CLOSED); private final SlidingWindow window; private final int minRequests; private final double errorRateThreshold; private volatile long openedAt; public boolean isAllowed() { BreakerState current = state.get(); if (current == BreakerState.OPEN) { long now = System.currentTimeMillis(); if (now - openedAt >= 30000L) { // 达到半开时间窗口,只放行少量测试流量 state.compareAndSet(BreakerState.OPEN, BreakerState.HALF_OPEN); } else { return false; } } if (current == BreakerState.HALF_OPEN) { // 半开状态下只放行预先设定的比例,这里设为 20% return ThreadLocalRandom.current().nextDouble(100) < 20; } return true; } public void onSuccess() { window.recordSuccess(); if (state.get() == BreakerState.HALF_OPEN) { // 半开状态下成功一次立即关断,优先保障可用性 state.compareAndSet(BreakerState.HALF_OPEN, BreakerState.CLOSED); } } public void onError(Throwable t) { window.recordError(); if (state.get() == BreakerState.HALF_OPEN) { state.compareAndSet(BreakerState.HALF_OPEN, BreakerState.OPEN); openedAt = System.currentTimeMillis(); window.reset(); return; } checkAndTripBreaker(); } private void checkAndTripBreaker() { if (window.total() < minRequests) { return; } double errorRate = window.errorCount() * 1.0 / window.total(); if (errorRate >= errorRateThreshold) { if (state.compareAndSet(BreakerState.CLOSED, BreakerState.OPEN)) { openedAt = System.currentTimeMillis(); window.reset(); } } } }这里有两个地方值得解释。半开状态下的放行比例如 20% 不是拍脑袋定的,它代表“用少量流量去试探上游是否恢复”的代价——放行比例太小,探测不到真实情况;放行比例太大,如果上游还没恢复,又会让一批用户受影响。我压测过几组比例,20% 在 30 秒的半开窗口里,既能让系统快速恢复,也控制了试错成本。另外,半开状态下成功一次就关闭熔断器,这一步是刻意为之,因为 AI 在线服务的恢复通常比较快,一线探测成功就说明上游已经稳定,优先把系统拉回正常状态比等待更多样本更重要。
3.4 聚合调度层:让熔断器和优先级在一个 API 下协同工作
基础设施都有了,还要把它们组装成一个对外统一的入口来处理 AI 请求。我定义了一个 AiCallTemplate 类作为整个 AI 调用治理的骨架入口,提供两个核心方法:submitPriority 用于非阻塞优先级调度,execute 用于同步执行并附带熔断保护。
public class AiCallTemplate { private final AiCircuitBreaker breaker; private final Map<TaskPriority, ThreadPoolExecutor> executors; private final AiFallbackHandler fallbackHandler; public <T> T execute(AiRequest request, TaskPriority priority, AiCallable<T> callable) { if (!breaker.isAllowed()) { return fallbackHandler.onFallback(request); } try { T result = callable.call(); breaker.onSuccess(); return result; } catch (AiServiceUnavailableException e) { breaker.onError(e); return fallbackHandler.onFallback(request); } catch (Exception e) { breaker.onError(e); throw new BizException("AI 调用异常,请稍后重试"); } } public void submitPriority(AiRequest request, TaskPriority priority, AiTask task) { ThreadPoolExecutor executor = executors.get(priority); executor.execute(new PriorityTask(() -> { if (!breaker.isAllowed()) { fallbackHandler.onFallback(request); return; } try { task.run(); breaker.onSuccess(); } catch (AiServiceUnavailableException e) { breaker.onError(e); fallbackHandler.onFallback(request); } }, priority)); } }这里在异常处理上有一个特别重要的原则:上游返回普通人看不懂的底层异常时,绝不能让这个异常直接传导给调用方。AI 上游以“服务暂时过载”或者“上下文长度超限”作为错误返回很正常,但直接把这个错误抛给上层业务,会让上层无从应对。我在模板层里做了一个异常平移:上游的错误码统一映射到几个内部异常类型,然后再决定是走降级还是直接给接口调用方返回业务错误。
另外,execute 方法内部没有显式地做线程池调度,它在调用方线程里同步执行。这样设计是因为很多业务场景希望拿 AI 返回结果继续做后续处理,如果又给它套一层异步,会让链路变复杂;而 submitPriority 是给那些可以“发完就忘”的任务用的,比如触发一个文档的 AI 摘要生成,后台慢慢算就行。两种模式的区分非常重要,它能省掉大量不必要的异步包装代码。
3.5 多级降级链路的请求上下文设计
前面提到降级链路包含实时 AI、缓存命中、轻量兜底、静态提示四个阶梯。在代码实现里,我定义了一个降级结果结构:
public class AiResponse { private final String content; private final AiSource source; // REALTIME, CACHE, LIGHTWEIGHT, STATIC private final String requestId; private final long costMs; }source 字段是给监控用的,我们可以实时统计系统里有多少请求是从 REALTIME 走的、有多少是 CACHE 命中的、有多少已经掉到 STATIC 了。这个数据在故障复盘时非常重要——它能告诉你是“上游恢复了但缓存还在控流”呢,还是“降级链路长时间兜底但是没人发现”。
降级链路还有一层更精细的控制:AI 请求的上下文裁剪。很多 AI 请求失败是因为 prompt 过长、超过模型的上下文限制,这时候与其直接失败,不如做一次 prompt 压缩再重试。我实现了一个简化版的 PromptTruncator,按“从后往前裁剪对话历史、只保留最近两轮对话、优先保留 system prompt”的顺序压缩。这种降级可能不是一个 100% 完美的结果,但他保住了整个请求能成功返回,实际体验比给用户一个硬错误要好得多。
4. 参数配置与监控告警:工程化落地的最后一公里
4.1 优先级分组:不同生产场景的策略参考
我把 AI 调用按业务场景分成了三档,每一档对应不同的线程池隔离策略和熔断参数。这里给出一份可以直接抄的参考配置,但我们每个人的业务不一样,需要结合自身情况调整:
| 优先级 | 典型场景 | 线程池策略 | 超时阈值 | 熔断错误率阈值 | 半开窗口 |
|---|---|---|---|---|---|
| P0 | 实时问答、智能客服转人工前兜底 | 专属线程池 2-8 | 5s | 20% | 15s |
| P1 | AI 辅助代码 review、自动测试生成 | 专属线程池 1-4 | 8s | 30% | 30s |
| P2 | 文档批量摘要、历史数据清洗 | 共享线程池 1-2 | 15s | 50% | 60s |
P0 的超时阈值 5 秒看起来非常激进?AI 实时问答确实可能超过 5 秒,但我们通过超时控制倒逼上游模型服务压测优化。对于用户正在等待的请求,5 秒是体验红线,继续等下去用户早就走了。这个参数和“P0 是否应该更宽容”是不同团队有不同取舍的话题,但我个人经验是:让高优请求尽快失败走降级,也比无限期等待让用户挂断连接更合理。
P2 的线程池只有 1-2 个线程,不是不想给更多资源,而是因为这类任务往往是批量循环执行的,本来就不追求速度,给多了反而会和 P0/P1 抢 CPU。同时 P2 处理的是一些“失败一次也无所谓”的任务,即使被降级成静态兜底也没有人有感知。
4.2 熔断窗口统计:环形数组滑动窗口的实现细节
熔断器里的滑动窗口统计,我用了固定容量的环形数组实现,而不是把所有请求时间戳全存下来。这么做的好处是内存占用恒定,不受流量峰值影响。核心思想是把时间切成固定大小的切片,比如每 1 秒一个切片,总共保留最近 60 个切片,请求进来时根据时间戳找到对应切片并计数。时间推进后,旧切片会被新切片覆盖,滑动窗口的效果就自然形成了。在数据结构的选型上,每个切片里用 LongAdder 做原子计数器,避免在高并发下使用 AtomicLong 形成热点争用。
不过这里也有个坑:当系统时钟发生跳变时,时间戳计算会出问题。我加了一层防御性代码,如果新请求的时间戳比当前窗口的最后时间还早,就直接丢弃这一条记录,不让脏数据污染统计。这种问题在故障演练时很容易暴露,本地怎么造都造不出来,但线上偶发时钟同步就会踩中。
4.3 监控指标:哪些数据必须被看到
治理框架上线后还要能“被看见”,否则出了问题就是黑盒。我在项目里埋了一套完整的指标采集点,定期上报到 Prometheus 和 Grafana。核心监控面板主要包括四个维度的数据:
- 调用量维度:按 Priority 区分 P0/P1/P2 的调用量曲线,观察各优先级流量的分布与波动。
- 熔断状态维度:熔断器的开关状态(CLOSED、OPEN、HALF_OPEN)变化时间点,这个数据能直观看到熔断有没有被触发、什么时候触发的。
- 降级来源维度:AiResponse.source 字段的实时占比统计,一眼能看出当前系统有多少请求是走缓存/静态兜底返回的。
- 延迟维度:按优先级分桶统计 P50/P95/P99 响应时间,特别关注 P0 的 P99 是否超过 3 秒。
一旦 P0 的 P99 持续超过基线,或者降级来源中 REALTIME 占比骤降,告警规则就会立刻通知值班群。我见过很多人把熔断降级做完就以为万事大吉了,结果熔断器触发后没有任何监控知道,直到产品投诉用户大规模报错才去查日志。监控不是可选项,是工程化方案的一部分。
4.4 配置中心动态调整:一个教训和一套方案
最初上线这套系统时,我在代码里写死了几个关键的熔断参数。有一次线上 AI 服务上游模型切换,导致响应时间整体变慢,但错误率一直不高,熔断器死活不触发,可用户的 P99 已经涨到不可接受了。后来我紧急改代码、重新打包、灰度、发布,整整花了四十分钟。之后我就把熔断的所有参数全部挪到了配置中心,通过配置变更即时生效,还加了审计日志,谁改了什么、什么时候改的都能追溯。
现在项目的配置中心里有两组典型的配置:应用启动时加载的静态默认值 + 运行时可动态覆盖的阈值。运行时可调项包括:熔断错误率阈值、慢调用阈值、最小请求数、半开窗口时长、半开放行比例、P0/P1/P2 的超时阈值。Config Change 的回调函数已经内置了热更新的逻辑,不用重启 JVM。这种“有配置开关,不做硬编码”的思路对整个 AI 治理体系的运维价值极其明显,可以让系统在故障时快速响应、快速恢复。
5. 常见问题与故障排查:这些坑我替你试过了
5.1 高频问题速查表
我把这个项目上线以来遇到的典型问题整理成了一张表,按症状排序列出来,方便大家直接对照排查:
| 症状表现 | 可能原因 | 解决手段 |
|---|---|---|
| P0 请求响应突然飙到 10 秒以上 | 优先级队列生效但线程池 worker 被 P2 慢任务占满 | 拆分为多级线程池隔离,P0 独立线程池 |
| 熔断频繁误触,AI 调用量低于预期 | 最小请求数阈值设置过低,1 次超时就触发熔断 | 提高 minRequests,如设置到 10 次以上 |
| 上游恢复后熔断迟迟不关 | 半开状态探测流量比例太低,探测不到成功样本 | 提高半开放行比例,同时缩短半开窗口 |
| 错误率不高但用户感知很差 | 慢调用比例超标,触发条件配置有遗漏 | 增加慢调用比例熔断条件,并区分业务场景阈值 |
| 配置中心修改阈值后不生效 | 熔断状态机从 OPEN 转到 CLOSED 的过程没有检查新配置 | 在状态迁移时重新读取最新配置值 |
| 内存持续上涨,疑似泄漏 | 滑动窗口对象没复用,每个请求新建了一个窗口 | 改为用环形数组 + 预分配的窗口切片,复用对象 |
这些问题的共性根源,基本都指向了“参数设置”和“状态管理”这两个方向。调参不是玄学,每改一个参数,都要看监控面板上对应的指标变化,用数据说话。
5.2 重试风暴:一个必须提前防住的连环故障
AI 调用超时之后,如果客户端 SDK 默认开启了重试,系统很容易出现“重试风暴”。一次超时会触发重试,重试又超时,再触发下一次重试,等于把 AI 上游的压力又放大了一倍。而且重试请求往往会跳过熔断器检查(因为它是内部的二次尝试,不是新的入口请求),导致熔断器的保护形同虚设。这个问题的解法有两步:第一,在 AI SDK 层关闭自动重试,重试逻辑统一由我们的调用层来控制;第二,重试要遵守退避策略和熔断状态检查,每次重试前先确认熔断器是 CLOSED 状态,否则直接走降级,不再向上游发起无效调用。这个过程称为重试熔断联动,是我在接入多个 AI 大模型供应商后总结出来的必备设计。
还有一点容易被忽略:AI 请求的重试往往要重新计算 token 费用。一次超时重试,用户感知是一次请求,但账单上是两次生成,成本直接翻倍。所以我在日志里专门加了一条“重试费用统计”的指标,用来追踪每个请求因为重试产生了多少额外消耗。当这个指标异常升高时,说明重试策略有问题,需要调低重试次数或调整超时阈值。
5.3 降级链路不可用:缓存、静态文案也要纳入健康检查
有次凌晨,上游 AI 厂商限流,系统自动降级到缓存方案。结果崩溃了,因为本地缓存因为内存回收被清空了,导致大批请求一路从缓存降级到静态提示。用户半夜反馈体验非常差。事后我反思了一个问题:降级链路本身也要像主链路一样做好可用性保障。
我的解决办法是隔离降级数据源:缓存降级使用 Redis 这一独立存储,设置合理的 TTL 和持久化策略,不依赖本地内存;静态提示文案放在配置中心和 CDN 里,即使服务重启也能第一时间恢复。同时给降级链路也配了健康检查和告警,一旦出现缓存命中率过低、静态兜底占比增高等情况,第一时间通知值班人员。降级链路的可靠性,是整个方案稳定性的基础之一。
5.4 压测结果不如预期:用数据说话排查根因
压测时最容易遇到的问题就是结果不如预期,无论怎么调熔断参数,系统的吞吐量上不去。这个时候不要凭感觉盲调,最好用压测报告里的数据做根因分析。举个例子,某次压测显示 AI 线程池利用率只有 60%,但 P0 的 P99 却已经超了。我的排查顺序是:第一看线程池的活跃线程数和队列大小,确认任务是否在队列里堆积;第二看每个任务的耗时占比,区分是网络 I/O 等待还是模型处理耗时;第三看是否触发了 CallerRunsPolicy,因为任务被调用方线程同步执行时,会把耗时转移到上层 Controller 线程,反而拖慢了整体响应。最后发现是上游某个模型在长文本场景下处理时间不稳定,走了多次重试才成功,真正执行 AI 调用的时间只有总耗时的 30%。定位到根因后,把慢模型流量切走一部分给更快的模型,并对长文本场景单独设置更严格的超时,系统整体吞吐直接翻倍。
这就是为什么监控指标里必须同时有“队列堆积”和“单次调用耗时”这类数据的原因。看不到数据,分析就是盲人摸象。我强烈建议在设计阶段就预留好这些指标的采集点,不要等上线后再补——补的成本比想象中高得多。
6. AI 生命线的三个“不为清单”
项目上线半年,大故障出现过一次,小问题不断,但这套体系跑下来总体稳定。总结几个我认为最有价值的原则,分享给正在做类似事情的团队。
第一,不要轻易让 AI 调用穿透业务主链路而不设防线。每一处 AI 调用都是系统的潜在弱点,必须对这个调用点做一次“故障影响评估”:最坏情况下它挂了,业务怎么兜底。没有答案的调用点一律不允许上线。
第二,不要把所有 AI 请求一视同仁,更不要把优先级当成可有可无的“加分项”。当 AI 成为系统的核心依赖之后,没有优先级控制就相当于让救护车和货车在同一个车道上堵车,系统吞吐再高也救不了体验。
第三,不要追求一次到位的大而全方案。我见过有些团队一开始就想做个很完美的治理平台,包括各种花哨的规则引擎和可视化编排,结果边界越铺越大,迟迟落不了地。更务实的路径是:先做好线程池隔离和优先级队列,再接入熔断器,接着完善降级链路和监控告警,每一步都能独立验证收益、回滚风险也小。
最后再分享一个实际经验,每次上线新的 AI 调用点之前,我都会做一次“故障演练”:手动把上游服务的超时时间改成 100ms,模拟不可用,观察系统是不是能按设计自动降到正确的降级路径。如果连故障演练都不能过关,那这套体系还不能算是工程化的,只能算写了代码而已。守住 AI 生命线的关键,从来不是哪个单独的框架或算法,而是把每一项保护措施都当作主业务的一部分去设计、去测试、去运维。