Administrator
发布于 2025-08-05 / 2281 阅读
42

Spring AI 与现有微服务架构的融合

四个服务里塞了四份大模型调用代码

年初我们把 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 与现有微服务架构的融合》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考