四个服务里塞了四份大模型调用代码
年初我们把 AI 能力往业务里铺的时候,图快,哪个服务需要就直接引一份 spring-ai-openai,配个 key 开干。到六月底盘点,订单服务、客服服务、商品服务、报表服务里各有一套调用代码,四份 application.yml 里躺着四个 API Key。
真正让我下定决心重构的是一次故障:商品服务做批量标题生成,一口气发了 3000 个请求,把账号的每分钟请求数(RPM)上限打满了。同一时间客服机器人在跟用户对话,返回的全是 429,持续了 11 分钟。客服先发现的,我在群里被 @ 的时候还不知道是自己人打的。
问题很清楚:AI 能力散在各个服务里,没有任何一层能站在全局视角做限流和调度。
把 AI 能力收口成一个服务
我们的做法不是加个网关代理了事,而是把「模型调用」这件事本身建模成一个有状态的服务,注册进 Nacos,跟其他微服务平级。
// ai-gateway 的领域划分:一个能力一个 Spring Bean,而不是一个 Controller 方法
public interface LlmCapability {
String name();
int weight(); // 负载均衡权重
Completion call(CapabilityRequest req);
}
@Component
class TitleGenerationCapability implements LlmCapability {
public String name() { return "title-gen"; }
public int weight() { return 3; } // 批处理型,权重低
...
}
@Component
class ChatCapability implements LlmCapability {
public String name() { return "chat"; }
public int weight() { return 8; } // 交互式,优先保障
...
}
业务服务不再直接依赖 Spring AI 的 ChatClient,而是通过 Feign 调 ai-gateway,请求里带能力名:
@FeignClient(name = "ai-gateway")
public interface AiGatewayClient {
@PostMapping("/capabilities/{name}/invoke")
CapabilityResponse invoke(@PathVariable String name,
@RequestBody CapabilityRequest req);
}
这一层收口带来三个直接收益。一是 key 只剩一份,放在 ai-gateway 的 K8s Secret 里,轮换时只改一处。二是 RPM 可以在全局统一分配,不会再出现自己人打自己的情况。三是所有 token 消耗有了单一出口,账单终于能对上账了。
负载均衡不是轮询那么简单
注册进 Nacos 之后,ai-gateway 起了 6 个实例。默认的轮询负载均衡在 AI 场景下有明显问题:不同请求的代价差了几十倍。一个标题生成大概 800 token,一次带 RAG 的客服问答能到 12000 token。按请求数轮询,实例之间的负载能差 4 倍以上,我抓过一次监控,6 个实例里最忙的 CPU 78%,最闲的 19%。
我们换成了自己实现的按「预估代价」加最少并发的负载均衡:
public class CostAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
public Mono<Response<ServiceInstance>> choose(Request request) {
CapabilityRequest req = (CapabilityRequest) request.getContext();
return Mono.fromSupplier(() -> {
int estimate = tokenEstimator.estimate(req); // 输入长度 + 该能力的历史输出均值
return instances.stream()
.filter(inst -> inflight.get(inst) + estimate < inst.getQuota())
.min(Comparator.comparingInt(inst -> inflight.get(inst)))
.orElse(leastLoadedInstance()); // 全部接近打满时退化为最少在途
});
}
}
这里的 inflight 是按实例统计的「在途预估 token 数」,每个请求进来加、回调里减。配额来自我们压测的结果:单实例 qwen-plus 并发 3 万 token 以内延迟可控,超过就开始排队。
上线后实例间的 CPU 极差从 59 个百分点降到 12 个百分点。
优先级队列:别让批处理打挂交互式
负载均衡解决的是均匀,解决不了「谁先谁后」。回到开头那次故障,真正需要的不是限流,是让批处理给交互式让路。
ai-gateway 内部按能力分了三条有界队列,用虚拟线程消费:
@Bean
ExecutorService dispatchExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
// 三个优先级队列,容量固定
private final BlockingQueue<Task> interactive = new ArrayBlockingQueue<>(2000);
private final BlockingQueue<Task> normal = new ArrayBlockingQueue<>(5000);
private final BlockingQueue<Task> batch = new ArrayBlockingQueue<>(20000);
Task takeNext() throws InterruptedException {
// 10:3:1 的加权取数,避免低优先级饿死
for (int i = 0; i < 10; i++) {
Task t = interactive.poll();
if (t != null) return t;
if (i % 3 == 0) { t = normal.poll(); if (t != null) return t; }
if (i % 10 == 0) { t = batch.poll(); if (t != null) return t; }
}
return batch.take();
}
队列满了的拒绝策略也分了级:批处理队列满直接返回 429 让调用方重试(业务方能接受延迟),交互式队列满则触发扩容而不是拒绝。这个差别很重要,我们一开始统一拒绝,结果大促时客服机器人疯狂报错。
容错:三类失败要分开处理
Resilience4j 我们一直在用,但 AI 调用的失败模式和普通 HTTP 调用不太一样,配置得重新想:
| 失败类型 | 特征 | 策略 |
|---|---|---|
| 限流 429 | 瞬时、可恢复,带 Retry-After | 按 Retry-After 退避,最多 3 次,跨实例重试 |
| 超时 | 长尾,输出越长越容易超时 | 熔断 + 降级到小模型 |
| 内容审核 / 参数错误 | 确定性失败,重试无用 | 快速失败,不计入熔断统计 |
第二类最值得说。大模型调用的耗时分布长尾极重,P50 是 1.2s 但 P99 能到 18s。如果对超时也做重试,等于给已经过载的模型再补一脚。我们的做法是超时优先降级模型而不是重试:qwen-max 超时就切 qwen-plus,质量差一点但至少能返回。
resilience4j.circuitbreaker:
instances:
llmMax:
slidingWindowType: COUNT_BASED
slidingWindowSize: 50
failureRateThreshold: 40 # 比普通服务宽松,模型抖动本来就多
minimumNumberOfCalls: 20
waitDurationInOpenState: 15s
recordExceptions:
- java.net.SocketTimeoutException
ignoreExceptions:
- com.example.llm.ContentFilterException # 不进熔断统计
failureRateThreshold 设 40% 而不是常见的 50%、20%,是拿线上数据调出来的。设 50% 时熔断太迟钝,等触发了用户已经卡了半分钟;设 20% 又太敏感,模型厂商偶尔抖一下就整体降级。
成本归因:把钱算到业务头上
收口之后还有一件以前做不到的事——成本归因。以前四个服务各自调模型,账单是一笔糊涂账,业务方问「我这个功能花了多少钱」根本答不上来。
现在每次调用都带业务标识,落库时记三列:
CREATE TABLE llm_cost_daily (
dt DATE NOT NULL,
biz_line VARCHAR(32) NOT NULL, -- 业务线:order / cs / product / report
capability VARCHAR(64) NOT NULL, -- 能力名
model VARCHAR(32) NOT NULL,
req_count BIGINT NOT NULL,
input_tok BIGINT NOT NULL,
output_tok BIGINT NOT NULL,
cached_tok BIGINT NOT NULL, -- 命中缓存的 token,单独记
amount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (dt, biz_line, capability, model)
);
业务标识通过 MDC 透传,调用方在 Feign 请求头里带 X-Biz-Line。我们加了拦截器强制校验,缺这个头直接拒绝——不强制的话两周后就没人带了。这也是收口带来的好处:以前四个服务各自调用,想加这种强制约束根本加不上去。
跑了两个月,数据推翻了两个我们之前的共识。一是客服场景的钱主要花在少数超长会话上:3.7% 的会话(超过 20 轮)消耗了 44% 的成本。二是商品标题生成看似用量大,但因为可以批处理 + 用便宜模型,实际只占总成本的 8%。
基于第一个发现,我们给超长会话加了强制摘要(超过 15 轮自动压缩历史),客服线的月度成本从 1.1 万降到 6,400。这个优化在收口之前根本不可能做——因为客服和商品服务的调用混在一起,看不出是哪边的问题。
灰度与多模型路由
收口之后有个意外收获:换模型变成了一行配置的事,不用发版。我们把模型路由做成了 Nacos 动态配置,按能力名和流量比例分配:
{
"title-gen": [{"model": "qwen-plus", "ratio": 100}],
"chat": [{"model": "qwen-max", "ratio": 90},
{"model": "deepseek-v3", "ratio": 10}]
}
七月份我们用这个能力做了 DeepSeek-V3 的灰度,10% 流量跑了九天,对比准确率和成本,确认没问题后才全量。如果没有这一层,改模型要动四个服务、发四次版。
配置热更新用 Nacos 的长轮询,改完 3 秒内生效。这里有个细节:切流时正在进行的请求不能受影响,所以路由决策只在任务开始时做一次,中途不换模型——否则会出现一半输出来自 A 模型、一半来自 B 模型的情况。
踩过的坑
- 别在业务服务里缓存 ChatClient。Spring AI 的
ChatClient是不可变 builder 产物,我们一开始把它做成单例塞进 Spring 容器,结果 advisor 链里的会话状态串了,两个用户的对话历史混在一起。正确做法是每次请求按会话 ID 构建。 - 流式响应和 Feign 的超时配置打架。SSE 响应的首个 token 可能要等很久,但 Feign 默认的连接超时是 10s。我们给流式接口单独配了
ReadTimeout: 120000,同时把心跳注释每 15s 发一次防止被网关掐断。 - 健康检查要真检查。ai-gateway 的健康检查一开始只探活端口,模型账号欠费的实例照样接流量,返回全是错误。后来改成定期发一个 1 token 的探针请求,失败就从注册中心摘掉;
- 别忘了模型侧的限流。我们自己的队列和熔断做得挺全,但有次还是被厂商限流打回来了——因为多个实例加起来超过了账号的全局 RPM。现在 ai-gateway 用 Redis 做了一个全局令牌桶,配额按账号维度分配,实例内的队列只是第二道防线。
下篇预告
这篇先把《Spring AI 与现有微服务架构的融合》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。