ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain4J多模型整合实战:Spring Boot中的路由与降级设计

LangChain4J多模型整合实战:Spring Boot中的路由与降级设计

聊到 Java 生态里的 AI 应用,LangChain4J 算是一个绕不开的名字。它把大模型的调用抽象成几个简洁的接口,让后端同学可以像操作普通 Java 对象一样操作各种大模型。但真正用起来之后,很多人会发现一个尴尬的事实:写一个模型调用 Demo 容易,把多个模型稳定地接进同一个业务系统,则是另一回事。

这篇文章想写的,不是官方文档的搬运,而是我自己在一个真实项目里整合多家模型供应商时,从设计到落地的完整思考、核心实现步骤,以及过程中踩到过的那些坑。内容会比较干,适合对 LangChain4J 有基本了解、想在 Spring Boot 项目里做多模型切换、路由、降级和灰度策略的同学参考。看完之后,你应该能直接把这套结构搬到自己的工程里。

1. 为什么要在 Java 应用里整合多 AI 模型?——动手前的四个场景

1.1 四个真实场景驱动多模型需求

先说一个最常见的误区:很多人以为“整合多模型”是为了炫技,或者是为了在一个页面里展示多个模型的能力。真正落到业务里,多模型需求往往是下面四种情况逼出来的。

第一种是供应商单点风险。假设你的核心业务对话全部挂在一家模型供应商上,某一天它突然限流、服务抖动或者上线了一个效果变差的新版本,你的用户立刻感知到“机器人变蠢了”。这时候如果系统里还接了一家备选模型,几行配置就能切过去,至少不会线上事故。

第二种是任务形态差异。通用对话、复杂推理、信息抽取、内容审核,这些任务对模型能力要求完全不同。用同一个大参数模型处理所有请求,成本高且响应慢;用一个小参数模型做复杂推理,效果又不行。最合理的方案就是按任务类型路由到不同的模型。

第三种是成本与灰度。新模型上线的时候,直接全量切流量是很危险的事情。更稳妥的做法是让 5% 的流量先走新模型,对比效果和成本,没问题再逐步放量。这就天然要求系统具备按权重路由的能力。

第四种是数据合规。有些业务数据不能出内网,只能用本地部署的模型处理;另一些公开数据可以放心调用云端模型。如果不在应用层做一个“数据边界”,让模型自己去选,迟早出事。

1.2 整合多模型不是“多接几个 SDK”

面对这些需求,最直接的想法可能是:每个供应商都有自己的 SDK,我多引入几个依赖,在需要的地方直接 new 对应的 Client 不就行了?

这样做在小项目里确实能用,但代码会很快失控。每个 SDK 的请求对象、响应对象、错误码、超时策略、重试机制都不一样。你在业务代码里到处写 if (provider == A) aClient.chat(...) else if (provider == B) bClient.chat(...),每次新增一个模型都要改所有调用点。等模型数量超过三个,这个项目基本就变成一个维护黑洞。

我习惯用插线板的类比来解释这个问题:你的业务是那面墙上的插座,每个模型供应商都是一台家电。如果每个家电都要自己特殊的插头规格,那家里就会堆满各种转换头。而整合多模型要做的事情,是设计一个统一的插座标准,然后给每个模型做一个转换插头。业务代码永远只需要面对那个标准插座,新增一台家电时只需要做一个新的转换插头。

1.3 选 LangChain4J 而不是自己封装 SDK 的原因

那为什么不自己封装一套接口,而是选 LangChain4J?我之前确实认真对比过两条路。

自己封装的好处是可控性强,完全按业务需求设计。但坏处也很明显:你需要自己处理流式调用、对话历史、工具调用、向量化、RAG、Token 统计这些通用能力。这些能力看起来不复杂,真正实现起来每一块都是深坑。比如流式响应的背压处理、不同厂商 SSE 事件格式差异、Token 计费口径不统一,单靠一个人很难在短时间内做到完善。

LangChain4J 的价值在于它把这些通用问题已经解决了一遍。它提供了几个非常关键的抽象:ChatModel处理同步对话,StreamingChatModel处理流式对话,EmbeddingModel处理向量化,AiServices做链路编排。不同的底层模型只需要实现这些接口,业务层就可以完全面向接口编程。

当然,LangChain4J 也有它的问题。API 版本演进比较快,网上很多教程一两个月就失效;文档有时候不够细,遇到问题要翻源码。但对 Java 后端来说,它仍然是目前最划算的路线。

2. 整合方案的整体设计与路由抽象

2.1 统一抽象:ChatModel 与模型工厂

LangChain4J 里最核心的接口是ChatModel,它的职责非常简单:接收一个ChatRequest,返回一个ChatResponse。这个接口基本等价于一个“大脑执行器”,你给它一段对话列表,它返回模型的回复和元信息。

多模型整合的第一性原理,就是把所有模型供应商的实现类都收敛到ChatModel这个接口后面。具体方式是在启动时根据配置构造多个ChatModel实现,放到一个Map里,key 是模型标识,value 是对应的模型实例。业务调用的时候,只需要告诉路由器“我要用哪个模型”,剩下的就交给了 Map 查询和接口调用。

这个思路一点都不新鲜,本质上就是“策略模式” + “工厂模式”。但很多人在实现时容易犯一个错误:只把模型实例放进了 Map,却没有把路由和降级逻辑也抽象出来。结果就是模型是切了,但降级逻辑还散落在每个业务 Service 里,过几天又开始乱。

2.2 三种常见接入拓扑

我在项目里梳理过三种多模型接入的拓扑,理解它们之间的差异非常关键。

第一种叫点对点接入。直接在某一个业务 Service 里注入一个具体的ChatModel实现类,或者通过@Qualifier指定一个 Bean。这种方式最简单,适合只有一个模型、短期也没有切换计划的模块。但它的缺点很明显:业务代码和模型实现绑死,后续想加模型几乎要重写。

第二种叫静态模型池。启动时构建一个 Map,把多个ChatModel塞进去。调用时通过Map.get(key)取出目标模型。这种方式已经能满足“多个模型并存”的需求,切模型只需要改 key,不需要改业务代码。但如果要做权重路由、灰度、降级,你还需要在外面包一层逻辑。

第三种叫动态路由器。自己实现一个ChatModel接口,内部持有模型池和路由策略,对外仍然是一个普通ChatModel。业务层完全感知不到底层有多个模型,它只知道自己在调用“某个模型”。动态路由器把路由决策、异常降级、灰度策略、审计日志全部集中到一个地方,是生产环境最推荐的形态。

我做的项目中最终采用的是第三种:一个RouterChatModel作为门面,内部持有模型池、权重策略和降级策略。这样做的好处是,业务层只注入一个ChatModel,后续模型怎么路由、降级到哪个模型,都不需要业务方关心。

2.3 配置驱动设计:Model 实例池与路由规则

多模型整合的另一个关键设计原则是“配置驱动”。所有模型实例的参数、路由规则、权重比例、降级顺序,都必须外部化,不能硬编码在代码里。

有人可能会问,把模型地址和 API Key 写在配置文件里,和写在代码里有什么区别?区别在于,代码变更需要重新发布整个应用,而配置变更只需要触发配置刷新。尤其是线上故障切换模型的场景,你大概率希望运维同学改一行配置就能把流量切走,而不是半夜把开发叫起来改代码发版本。

所以我后来把模型相关配置统一收敛在一块,包括每个模型的 baseUrl、apiKey、模型名、温度参数、超时时间、最大 Token 数、权重、是否启用的开关。路由规则单独放一块,支持按固定 key 路由、按权重随机、按业务标签映射。下面是这套配置在工程里的结构。

3. 核心实操:一步步把多模型接入 Spring Boot 工程

3.1 引入依赖并搭建工程骨架

先说一下工程环境,我用的是 Java 17 和 Spring Boot 3.x,因为 LangChain4J 对这两个版本的支持最完善。如果你还在用 Java 8 或 Spring Boot 2.x,也不是不能用,但要额外处理不少兼容问题,建议新项目直接上 17。

依赖方面,核心依赖是langchain4j,然后根据你接入的模型厂商,引入对应的实现模块。这里我特意不写死版本号,因为 LangChain4J 版本更新比较频繁,你直接去 Maven 中央仓库拉当前最新稳定版即可。

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 按实际接入的厂商引入对应模块 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-rest-compatible</artifactId> <version>${langchain4j.version}</version> </dependency>

工程骨架就按标准的 Spring Boot 多模块来:llm-core放模型抽象、路由和降级逻辑;llm-config放配置类;business-service放业务调用。这样分层的目的是避免业务代码和模型实现纠缠在一起。

3.2 用 YAML 配置管理多套模型参数

我的习惯是把所有模型配置放在一个以llm开头的配置前缀下面,这样既清晰又方便后续接入配置中心。下面是一个典型的application.yml片段。

llm: providers: provider-a: base-url: ${PROVIDER_A_BASE_URL:https://api.a.com/v1} api-key: ${PROVIDER_A_API_KEY:} model-name: demo-chat-large temperature: 0.7 timeout: 30s max-tokens: 1024 weight: 0.8 provider-b: base-url: ${PROVIDER_B_BASE_URL:https://api.b.com/v1} api-key: ${PROVIDER_B_API_KEY:} model-name: demo-chat-pro temperature: 0.3 timeout: 15s max-tokens: 2048 weight: 0.2 local: base-url: ${LOCAL_MODEL_BASE_URL:http://127.0.0.1:1234/v1} api-key: not-needed model-name: local-embedding-large temperature: 0.2 timeout: 60s max-tokens: 512 weight: 0

几个配置项值得单独说明。api-key坚决不要明文写在 YAML 里,要使用环境变量占位。timeout和max-tokens是每套模型独立设置的,因为不同模型服务商的响应速度和上下文窗口差异很大。weight是路由权重字段,生产环境做灰度时很有用,后面会详细讲。

3.3 构建多模型配置类与动态工厂

配置文件写好后,需要有一个配置类来承载它。我用 Spring Boot 的@ConfigurationProperties来做属性绑定,并提供一个@Configuration类负责构建模型实例。

@ConfigurationProperties(prefix = "llm") @Getter @Setter public class LlmProperties { private Map<String, ProviderConfig> providers = new HashMap<>(); @Getter @Setter public static class ProviderConfig { private String baseUrl; private String apiKey; private String modelName; private Double temperature = 0.7; private Duration timeout = Duration.ofSeconds(30); private Integer maxTokens = 1024; private Double weight = 1.0; } }

接下来是模型工厂。这一步最关键,也是整个工程里唯一需要考虑“适配不同厂商”的地方。如果你的所有厂商都兼容主流对话补全协议,那非常简单,可以直接用统一的ChatModel实现类。如果某个厂商协议不兼容,你需要在这个工厂里针对它单独做适配,把它包装成一个ChatModel实现返回。

@Configuration @RequiredArgsConstructor public class ChatModelConfig { @Bean public Map<String, ChatModel> chatModelMap(LlmProperties properties) { Map<String, ChatModel> map = new HashMap<>(); for (Map.Entry<String, LlmProperties.ProviderConfig> entry : properties.getProviders().entrySet()) { String name = entry.getKey(); LlmProperties.ProviderConfig cfg = entry.getValue(); map.put(name, createChatModel(cfg)); } return map; } private ChatModel createChatModel(LlmProperties.ProviderConfig cfg) { // 这里返回的是 LangChain4J 的 ChatModel 接口实现。 // 如果协议兼容,用统一的构建器即可; // 协议不兼容时,这段代码要替换为针对该厂商的适配逻辑。 return CompatibleChatModelFactory.create(cfg); } }

你可能会问,为什么这里返回的是一个Map<String, ChatModel>而不是单个ChatModel?因为 Spring 容器在注入Map<String, ChatModel>时会把所有ChatModel类型的 Bean 都收集进去,key 就是 Bean 名称。这正好符合我们的模型池思路。不过要注意,如果整个应用里已经有其他ChatModelBean,需要认真规划 Bean 名称,避免 key 冲突。

3.4 接入会话链路:从用户请求到模型路由

模型池建好之后,业务层不能直接面向Map编程,因为那样每次调用都要自己处理 key 不存在、模型降级等问题。我用一个RouterChatModel封装这部分逻辑,让它也实现ChatModel接口,对外伪装成一个普通模型。

@Slf4j @Component @RequiredArgsConstructor public class RouterChatModel implements ChatModel { private final Map<String, ChatModel> chatModelMap; @Override public ChatResponse generate(ChatRequest chatRequest) { // 从请求上下文中获取目标模型 key ChatSession session = sessionResolver.resolve(chatRequest); ChatModel target = chatModelMap.get(session.getModelKey()); if (target == null) { throw new IllegalArgumentException("未找到模型: " + session.getModelKey()); } log.info("模型路由命中: key={}", session.getModelKey()); return target.generate(chatRequest); } }

这里sessionResolver是我自己定义的一个组件,负责从用户请求、业务上下文或者请求头里解析出应该使用哪个模型。它内部可以先查用户级别配置,再查业务类型默认配置,最后查全局默认配置。这样任何业务模块调用时,都不需要感知路由规则。

业务 Service 的代码就非常干净了,看起来就像是在调用一个单一的模型:

@Service @RequiredArgsConstructor public class AiChatService { private final ChatModel chatModel; public ChatResponse chat(String userMessage) { return chatModel.generate(ChatRequest.builder() .messages(List.of(SystemMessage.from("你是AI助手"), UserMessage.from(userMessage))) .build()); } }

这就是整合多模型的第一步成果:业务层完全无感知,底层模型怎么路由、怎么切换,都是 RouterChatModel 的事。

4. 切换、降级与流量治理:保证生产可用

4.1 按权重路由与灰度发布

模型池和路由骨架搭好之后,接下来要解决的是“怎么选模型”。最简单的方式是按固定 key 选,用户 A 用模型 A,用户 B 用模型 B。但灰度场景下,你更希望的是“5% 的流量走新模型,95% 的流量走老模型”。

这个需求用权重轮询就能解决。注意,这里不是简单轮询,而是加权随机,这样才能保证流量比例稳定。我在配置里已经给每个供应商加了weight字段,路由时按权重比例随机选择。

@Slf4j @Component @RequiredArgsConstructor public class WeightedRouter implements Router { private final LlmProperties properties; @Override public String resolve() { List<ProviderConfig> enabledProviders = properties.getProviders().values().stream() .filter(c -> c.getWeight() != null && c.getWeight() > 0) .toList(); if (enabledProviders.isEmpty()) { throw new IllegalStateException("没有可用的模型权重配置"); } double totalWeight = enabledProviders.stream() .mapToDouble(ProviderConfig::getWeight) .sum(); double random = ThreadLocalRandom.current().nextDouble(0, totalWeight); double acc = 0; for (ProviderConfig provider : enabledProviders) { acc += provider.getWeight(); if (random < acc) { return provider.getModelName(); } } return enabledProviders.get(enabledProviders.size() - 1).getModelName(); } }

这段代码的逻辑很简单,算累积概率区间,随机数落在哪个区间就选哪个模型。但实际接入自己的工程时有一个关键细节:weight对应的 key 是provider.name还是modelName?我建议用配置里的 key,也就是 provider-a / provider-b 这种标识,而不是模型名,因为同一个模型名可能在多个 provider 中出现,用 provider key 能精确区分实例。

4.2 异常降级与重试策略

路由选到目标模型之后,调用可能失败。这时候就需要降级逻辑。我的降级策略比较简单:模型 A 失败后,自动切换到一个备用模型重试一次,而不是立刻返回错误。

降级的适用范围需要想清楚。超时、限流、5xx 这类服务端错误,适合降级。但 400 参数错误这类客户端错误,降级也没有意义,因为换个模型大概率还是同样的错误。所以我在实现时只对特定异常类型做降级处理。

@Slf4j @Component public class FallbackChatModel implements ChatModel { private final ChatModel primary; private final ChatModel fallback; @Override public ChatResponse generate(ChatRequest request) { try { return primary.generate(request); } catch (Exception e) { log.warn("主模型调用失败,切换到备用模型。primary={}, fallback={}, error={}", primary, fallback, e.getMessage()); return fallback.generate(request); } } }

重试策略这里多说一点。虽然降级本身可以看作一种重试,但更常见的做法是“同一模型重试一次,失败后再降级”。重试时要加指数退避,比如第一次失败等 200ms,第二次失败等 600ms,避免故障时瞬间打爆模型服务商。

还有一个容易忽略的问题:重试是否安全。如果请求只是读取对话结果,重试没有问题。但如果业务里触发了外部动作,比如“下单”或“发送消息”,重试就可能导致重复执行。所以重试之前要确认接口的幂等性。

4.3 成本与延迟观测

模型切来切去,最怕的是月底对账的时候发现成本暴涨,或者平均延迟翻倍了,却说不清楚是哪个模型导致的。所以我强烈建议在多模型网关层做一层指标埋点。

具体做法是再包一层ObservableChatModel,在调用前后记录耗时和结果。用 Micrometer 记几个关键指标:模型名称、调用成功数、调用失败数、耗时分布、输入 Token 数和输出 Token 数。这样后续接 Grafana 非常方便。

成本估算可以单独维护一张映射表,把每个模型每千 Token 的单价写进配置。每次调用结束后,根据用量估算本次调用成本,累加到一个指标里。虽然模型厂商账单才是权威,但至少你能在流量异常时第一时间发现成本拐点。

另外,不要只观测平均值。模型响应延迟的 P99 比平均值更能反映真实体验。因为少数慢请求会让用户明显感觉到“卡顿”,但平均值会被大多数快请求拉平。

5. 踩坑实录:多模型集成中常见问题与排查方法

5.1 路由不生效?先查 Bean 是否构建

我见过很多同学在自己项目里集成多模型后,第一步就卡住:明明配置了多个模型,但调用的时候总是命中同一个。排查这个问题,我的经验是按“配置读取 -> Bean 构建 -> 路由解析”三层顺序查。

先确认LlmProperties是否正确读取了配置。最简单的方式是在配置类里临时加一个ApplicationRunner,启动时打印一下providers的 key 集合。如果发现 key 是空的,八成是@ConfigurationProperties的前缀没对上。

再查模型 Bean 是否全部构建。如果你用了@Bean方法构建Map<String, ChatModel>,要确保方法返回值就是Map,而不是直接返回单个ChatModel。如果在某个@Service里注入List<ChatModel>,要小心 Spring 注入顺序和你配置顺序不一致,最好明确按 key 从 Map 取。

最后查路由解析逻辑。很多“路由不生效”的案例,问题根本不在于多模型配置,而在于路由 key 从请求里解析出来的值永远都是默认值。比如你按请求头X-Model-Key路由,但前端根本没传这个 header,那你取到的肯定是 null,然后就走默认分支。

5.2 流式调用的适配问题

如果业务场景要求打字机式输出,就不能只依赖ChatModel接口了。LangChain4J 有单独的StreamingChatModel接口,底层模型在流式返回时是不同的事件流。多模型整合时最容易踩的坑是:同步模型可以统一路由,但流式接口的返回类型和事件结构并不完全一样。

我当时的处理方式是不直接向业务层暴露StreamingChatModel,而是统一包装成响应式流。每个厂商的流式实现都转成一个统一的异步序列,业务层只感知这个序列,不感知底层厂商差异。这个封装层代码不算多,但很关键,它避免了业务代码出现if (model instanceof XxxStreamingModel)这种分支。

另外要特别留意流式连接的超时和心跳。有些模型的流式接口如果长时间没有输出,连接会被中间层断开。你需要在封装层加一个空闲超时检查和心跳机制,否则用户会发现“模型卡住不说话了”。

5.3 API Key 与安全配置实践

最后说一个安全性问题。很多项目在多模型集成时,因为要接入多个厂商,API Key 管理非常混乱。有人把 Key 直接写在 YAML 文件里提交到 Git 仓库,后面换个团队维护,Key 就相当于裸奔了。

我的实践是,所有 Key 一律通过环境变量或配置中心注入,YAML 里只写${VAR}占位符。本地开发时用自己的个人 Key,线上环境用独立的项目 Key,一旦泄露可以快速吊销只影响自己。

如果接入了本地模型服务,比如局域网内部署的模型,要注意这些服务通常没有认证机制。不要把本地模型端口暴露到公网,做一层内网访问白名单会保险很多。

还有一个看似很小但很容易踩的坑:日志输出。模型调用的请求和响应可能在调试阶段被日志框架打印出来,里面可能包含用户输入和模型回复,也可能包含模型调用链路上的敏感信息。在生成环境,我建议把这一类日志级别设为 WARN,避免数据意外落盘。

最后分享两个小技巧

第一,多模型整合这件事,我强烈建议先小步跑通,再去想花哨的动态路由和智能调度。第一步只需要把两个模型放进配置和模型池,让业务层能通过切换 key 调用到不同模型,这就已经解决 80% 的问题了。后面所有的权重、降级、观测,都是在这个骨架上长出来的。

第二,如果你想给模型加一个“一键切换”的测试页面,最快的办法不是写管理后台,而是直接用一个请求头参数穿透整个调用链路。在网关或拦截器里解析X-Model-Key,塞到请求上下文中,RouterChatModel读取这个值做路由。这样测试同学用一个浏览器插件就能模拟不同模型的效果,比做一套动态配置中心快得多。

我在实际项目里把这套结构搭完后,最大的感受是:LangChain4J 只是个工具,真正让多模型整合变得可维护的,是你愿意花多少精力把路由、降级、观测这些“模型之外”的逻辑设计好。把模型当成能随时拔插的零件,你的系统才不会被某一个供应商绑死。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进