☰
Java大模型网关工程化实战:Spring Boot+MyBatis+Maven从脚本到生产
2026/10/7 16:31:36 网站建设 项目流程

1. 从单体服务到网关层:为什么我要给大模型调用做一次"工程脱胎换骨"

去年下半年开始,团队里接入大模型的项目越来越多。最开始大家各写各的,A项目直接调某家API,B项目又封装了一套自己的重试逻辑,C项目干脆把密钥硬编码在配置文件里。等到要统一做限流、计费、审计的时候,我发现整个调用链路已经乱成一锅粥——这就是我决定动手做LLM Gateway的直接原因。

这个网关的定位很明确:它不是一个业务系统,而是所有大模型调用的统一入口。所有上游业务只认网关的接口,网关负责路由到不同的模型供应商、做密钥托管、做Token计量、做失败重试和降级。听起来像是一个反向代理,但比反向代理多了"懂大模型协议"这一层。

标题里说的"第2境-洞天境",是我给自己这套东西设的迭代阶段。第1境是能跑通,第2境是工程上要脱胎换骨——从"能用的脚本"变成"能上生产的服务"。这一境的核心工作,就是把整个项目用Java生态里最稳的那套组合重新搭一遍:Spring Boot做骨架,MyBatis做持久化,Maven做依赖和构建管理。关键词里出现的Java、LLM Gateway、Spring Boot、MyBatis、Maven,基本就是这一境的全部技术栈。

这篇文章适合谁看?如果你正在做类似的大模型统一接入层,或者你是一个Java后端工程师,想看看一个真实的网关项目在工程化阶段会踩哪些坑,那这篇内容应该对你有用。我不会只贴代码,更多是讲清楚每个决策背后的取舍——为什么用MyBatis而不是JPA,为什么Maven的依赖树要专门治理,为什么网关的线程模型不能照抄普通Web服务。

先说结论:这一境做完之后,网关的P99延迟从最初的800ms降到了120ms左右,配置变更从"改代码重启"变成了"改数据库热加载",密钥管理从散落各处收敛到了一个加密存储。这些数字背后,是一堆工程细节的堆叠。

2. 网关的核心职责拆解:它到底该管什么,不该管什么

2.1 大模型网关和普通API网关的本质区别

很多人第一反应是"这不就是个API网关吗,用现成的Spring Cloud Gateway不就行了"。我一开始也这么想,但真正动手之后发现,大模型网关有几个普通网关不具备的特性。

普通API网关关心的是路由、鉴权、限流,请求和响应都是短平快的。但大模型调用不一样:一次请求可能持续几十秒甚至几分钟(流式输出),Token消耗是计费的核心依据,不同供应商的协议差异巨大(有的用OpenAI格式,有的用自家格式),而且失败模式很特殊——不是简单的502,可能是"模型过载请稍后重试"这种需要语义识别的错误。

所以我的网关在设计上分了三层:接入层负责协议适配和鉴权,调度层负责路由选择和重试降级,计量层负责Token统计和配额扣减。这三层里,接入层和调度层是普通网关也有的,但计量层是大模型网关独有的。

2.2 为什么计量层必须独立出来

Token计量这件事,看起来简单,实际上坑很多。首先,不同模型的Token计算方式不同,有的按字符估算,有的有专门的tokenizer。其次,流式响应下,你需要在流式返回的过程中实时累加Token,而不是等全部返回完再算。最后,计量结果要能对账——用户看到的消耗和供应商账单必须能对上。

我把计量层做成独立的模块,通过Spring的事件机制和主流程解耦。每次调用完成后,发布一个LlmCallCompletedEvent,计量模块异步消费这个事件,做Token累加和配额扣减。这样做的好处是计量逻辑不会阻塞主调用链路,即使计量模块出问题,也不影响正常的模型调用。

public class LlmCallCompletedEvent { private String requestId; private String tenantId; private String modelName; private int promptTokens; private int completionTokens; private long latencyMs; private boolean success; // getters and setters }

这里有个经验:事件里一定要带requestId和tenantId,否则后续对账的时候你根本不知道这笔消耗是谁的。我一开始图省事没带tenantId,结果多租户场景下计量数据全混在一起,排查了半天。

2.3 不该管的事情要坚决不管

网关最容易犯的错误是什么都管。我见过有的网关连业务参数校验都做了,结果每次业务改字段都要动网关。我的原则是:网关只管和"模型调用"这件事直接相关的逻辑,业务语义一律不碰。

具体来说,网关不管:业务参数的业务含义(只做格式校验)、业务侧的权限模型(只做API Key级别的鉴权)、业务的重试策略(只做模型调用层面的重试)。这条边界划清楚之后,网关的变更频率大幅下降,稳定性自然就上来了。

3. Spring Boot骨架搭建:那些配置项背后的真实意图

3.1 端口、线程池与流式响应的适配

Spring Boot默认用Tomcat作为内嵌容器,但大模型网关的流式响应场景下,Tomcat的默认配置会出问题。默认的max-threads是200,听起来够用,但流式请求会长时间占用线程,200个线程很快就被占满。

我的做法是把网关的线程模型改成WebFlux,用Netty做底层。WebFlux的异步非阻塞模型天然适合流式场景,一个线程可以处理多个流式连接。但这里有个坑:WebFlux和MyBatis的阻塞式JDBC不兼容,如果你在WebFlux的线程里直接调MyBatis,会阻塞事件循环线程,性能反而更差。

解决方案是把数据库操作放到单独的线程池里,通过Schedulers.boundedElastic()切换。或者更彻底一点,把配置读取做成缓存,启动时加载到内存,运行时完全不碰数据库。我选的是后者,因为网关的配置变更频率很低,没必要每次请求都查库。

server: port: 8080 spring: codec: max-in-memory-size: 10MB webflux: base-path: /gateway

max-in-memory-size这个配置必须调大,默认是256KB,流式响应稍微长一点就会报DataBufferLimitException。我设成10MB,实测下来足够覆盖绝大多数场景。

3.2 配置分层:哪些放配置文件,哪些放数据库

Spring Boot的配置体系很灵活,但灵活意味着容易乱。我的分层原则是:基础设施配置(端口、线程池、连接池)放application.yml,业务配置(模型路由、供应商密钥、租户配额)放数据库。

为什么这么分?因为基础设施配置变更需要重启,放配置文件里改起来直观;业务配置需要热加载,放数据库里可以通过定时任务刷新。如果把模型路由写在yml里,每次加一个新模型都要重启网关,这在生产环境是不可接受的。

数据库里的配置表设计大概是这样:

表名用途刷新频率
llm_provider供应商信息(名称、base_url、密钥密文)低,手动触发
llm_model模型定义(名称、供应商、上下文长度、计费单价)低,手动触发
llm_route_rule路由规则(租户、场景到模型的映射)中,定时刷新
llm_tenant_quota租户配额(Token上限、QPS上限)高,实时更新

路由规则和配额的刷新频率不同,所以刷新机制也要分开。路由规则我用@Scheduled每30秒刷一次,配额则是每次调用前实时查(走本地缓存+定期同步)。

3.3 启动阶段的初始化顺序问题

Spring Boot的启动顺序是个容易被忽视的坑。网关启动时需要做几件事:加载数据库配置、初始化供应商客户端、预热路由缓存。这几件事有依赖关系,顺序错了就会出问题。

我一开始用@PostConstruct做初始化,结果发现供应商客户端初始化时数据库配置还没加载完,拿到的全是null。后来改用ApplicationRunner,并配合@Order注解控制顺序:

@Component @Order(1) public class ConfigLoader implements ApplicationRunner { @Override public void run(ApplicationArguments args) { // 先加载数据库配置 } } @Component @Order(2) public class ProviderInitializer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { // 再初始化供应商客户端 } }

@Order的值越小越先执行。这个细节看起来简单,但在多模块项目里,如果模块之间的初始化有依赖,不显式指定顺序就等着踩坑吧。

4. MyBatis在网关项目里的取舍:为什么不用JPA

4.1 网关的数据库访问特征决定了ORM选型

网关的数据库访问有两个特点:读多写少,且查询模式相对固定。读的是配置和配额,写的是调用日志和计量数据。这种场景下,MyBatis比JPA更合适。

JPA的优势在于对象关系映射的自动化,适合领域模型复杂的业务系统。但网关的配置表结构很简单,就是几张扁平的表,用JPA的自动映射反而增加了不确定性——你永远不知道Hibernate会生成什么样的SQL。MyBatis的SQL是手写的,执行计划可控,这对延迟敏感的网关来说很重要。

另一个原因是MyBatis对批量操作的支持更直接。网关的调用日志是高频写入,用MyBatis的foreach批量插入,比JPA的saveAll更可控。

<insert id="batchInsertLogs" parameterType="java.util.List"> INSERT INTO llm_call_log (request_id, tenant_id, model_name, prompt_tokens, completion_tokens, latency_ms, success, created_at) VALUES <foreach collection="list" item="item" separator=","> (#{item.requestId}, #{item.tenantId}, #{item.modelName}, #{item.promptTokens}, #{item.completionTokens}, #{item.latencyMs}, #{item.success}, NOW()) </foreach> </insert>

4.2 二级缓存的正确打开方式

MyBatis的二级缓存是个争议很大的特性。默认是关闭的,因为它的缓存粒度是Mapper级别,多个Mapper操作同一张表时容易出现脏数据。但在网关场景下,配置表的读频率极高、写频率极低,二级缓存其实很合适。

我的做法是只对配置查询开启二级缓存,并且配置flushInterval为60秒:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>

readOnly="true"很关键,它告诉MyBatis缓存的对象不会被修改,可以安全地共享引用。如果设成false,MyBatis每次返回缓存对象时都会做一次序列化拷贝,性能反而下降。

但这里有个坑:二级缓存是基于namespace的,如果你的配置查询分散在多个Mapper里,缓存就不共享了。所以我把所有配置查询集中到一个ConfigMapper里,确保缓存命中率。

4.3 动态数据源与读写分离的取舍

网关的日志写入量很大,理论上应该做读写分离。但我实际评估之后放弃了,原因是:网关的读操作(配置查询)已经被本地缓存和二级缓存挡住了,真正打到数据库的读请求很少;写操作(日志)虽然量大,但都是append-only,单表写入在SSD上完全扛得住。

做读写分离反而引入了主从延迟的问题——日志写入后立即查询可能查不到。对于网关这种对一致性要求不极端的场景,读写分离的收益小于复杂度成本。这个决策我犹豫了很久,最后用压测数据说服了自己:单实例MySQL在SSD上,每秒2万次insert的P99延迟在5ms以内,完全够用。

5. Maven依赖治理:从依赖地狱到清晰构建

5.1 依赖冲突的排查链路

网关项目依赖了Spring Boot、MyBatis、多个HTTP客户端(因为要对接不同供应商),依赖冲突几乎是必然的。我遇到的最典型的问题是:不同供应商的SDK依赖了不同版本的OkHttp,导致运行时NoSuchMethodError。

排查依赖冲突的标准流程是:

  1. mvn dependency:tree打印完整依赖树
  2. 搜索冲突的artifactId,看哪些路径引入了不同版本
  3. 用<exclusions>排除掉不需要的传递依赖
  4. 在<dependencyManagement>里统一版本
mvn dependency:tree -Dincludes=com.squareup.okhttp3:okhttp

这个命令只打印okhttp相关的依赖路径,比看完整依赖树高效得多。我建议把常用的排查命令做成alias,省得每次敲。

5.2 用dependencyManagement统一版本

多模块项目里,版本管理必须集中。我的做法是在父POM的<dependencyManagement>里声明所有关键依赖的版本,子模块引用时不写版本号。

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>3.2.0</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> </dependencies> </dependencyManagement>

spring-boot-dependencies用importscope引入,这样Spring Boot管理的所有依赖版本都会生效。MyBatis的starter版本要单独指定,因为Spring Boot的BOM里不一定包含最新版。

5.3 构建加速:从5分钟到40秒

网关项目模块多,全量构建一开始要5分钟。优化之后降到40秒,主要做了三件事:

第一,配置Maven的并行构建。在.mvn/maven.config里加上-T 1C,让Maven按CPU核心数并行构建模块。

第二,跳过不必要的插件。测试阶段用-DskipTests,打包阶段用-Dmaven.test.skip=true(注意这两个的区别:前者编译测试代码但不运行,后者连编译都跳过)。

第三,配置国内镜像仓库。这个不用多说,下载依赖的速度差异是数量级的。

<mirror> <id>aliyun</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/public</url> </mirror>

注意:镜像配置要放在settings.xml的<mirrors>节点里,不是pom.xml。很多人第一次配会放错地方。

6. 密钥托管与热加载:生产环境不能踩的坑

6.1 密钥绝不能明文存数据库

这是底线。我见过太多项目把API Key明文存在数据库里,一旦数据库被拖库,所有密钥全部泄露。我的做法是用AES加密存储,密钥本身放在环境变量里,不落盘。

@Component public class SecretEncryptor { private final SecretKeySpec keySpec; public SecretEncryptor(@Value("${gateway.secret.key}") String base64Key) { byte[] keyBytes = Base64.getDecoder().decode(base64Key); this.keySpec = new SecretKeySpec(keyBytes, "AES"); } public String encrypt(String plainText) { // AES/GCM加密 } public String decrypt(String cipherText) { // 解密 } }

用GCM模式而不是ECB或CBC,因为GCM自带完整性校验,能防止密文被篡改。环境变量通过K8s的Secret注入,或者用启动脚本从密钥管理服务拉取。

6.2 配置热加载的实现细节

热加载的核心是"不重启进程就能让新配置生效"。我的实现是:配置存在数据库里,网关本地维护一份缓存,定时任务每30秒拉取一次变更。

但这里有个并发问题:刷新缓存的时候,正在处理的请求可能读到半新半旧的配置。解决方案是用AtomicReference做整体替换,而不是逐个字段更新。

private final AtomicReference<GatewayConfig> configRef = new AtomicReference<>(); public void refresh() { GatewayConfig newConfig = loadFromDatabase(); configRef.set(newConfig); // 原子替换 } public GatewayConfig getConfig() { return configRef.get(); }

AtomicReference.set()是原子操作,正在读的请求要么拿到旧配置,要么拿到新配置,不会拿到混合状态。这个模式在配置热加载场景下非常实用。

6.3 密钥轮换的平滑过渡

密钥轮换是个容易被忽视的场景。供应商的API Key需要定期更换,但更换过程中不能让服务中断。我的做法是支持双密钥并存:新密钥生效后,旧密钥保留一段时间,等确认没有请求再用旧密钥时,才真正删除。

数据库表里加两个字段:secret_current和secret_previous,以及一个rotation_time。路由时优先用current,如果current调用失败且距离rotation_time不超过1小时,自动降级用previous重试一次。这样即使轮换时新密钥有问题,也有兜底。

7. 实测数据与踩坑复盘

7.1 性能压测的真实结果

用JMeter对网关做压测,场景是模拟100并发持续调用,请求体平均2KB,响应体平均4KB(非流式)。结果如下:

指标优化前优化后
P50延迟320ms45ms
P99延迟800ms120ms
吞吐量180 TPS1200 TPS
错误率2.3%0.01%

优化前后的差异主要来自三处:线程模型从Tomcat换成Netty、配置查询从实时查库改成内存缓存、日志写入从同步改成异步批量。

7.2 踩过的三个典型坑

第一个坑是流式响应的超时配置。WebFlux的默认超时是30秒,但大模型流式输出可能持续几分钟。需要在WebClient上单独配置responseTimeout,而且这个超时是"两次数据之间的间隔",不是总时长。

WebClient.builder() .clientConnector(new ReactorClientHttpConnector( HttpClient.create().responseTimeout(Duration.ofSeconds(120)))) .build();

第二个坑是MyBatis的LocalDateTime映射。MySQL的datetime类型和Java的LocalDateTime默认能映射,但如果你用了mybatis-spring-boot-starter的3.0.x版本,需要显式注册LocalDateTimeTypeHandler,否则会报No typehandler found。

第三个坑是Maven的providedscope。网关项目里有些依赖是运行时才需要的(比如数据库驱动),如果你在父POM里统一声明成provided,子模块打包时会丢失。正确做法是在需要的地方单独声明runtimescope。

7.3 监控埋点的最小必要集

网关的监控不需要面面俱到,但有几个指标必须有:每个模型的调用成功率、P99延迟、Token消耗速率、配额剩余量。我用Micrometer + Prometheus做指标暴露,Grafana做展示。

Timer.builder("llm.call.duration") .tag("model", modelName) .tag("provider", providerName) .register(meterRegistry) .record(() -> doCall());

Tag的维度要控制好,太多会导致指标基数爆炸。我只打了model和provider两个tag,租户维度的统计走日志分析,不进Prometheus。

8. 这一境之后,网关还能往哪走

第2境做完,网关的工程底座算是稳了。但我知道还有几件事没做:多供应商的智能路由(根据实时延迟和成功率动态选路)、基于语义的缓存(相似问题复用结果)、更细粒度的配额策略(按部门、按项目而不是按租户)。

智能路由我打算放在第3境做,核心思路是维护每个供应商的实时健康分,路由时按分数加权随机。语义缓存则要谨慎,因为大模型的输出有随机性,缓存命中率可能不高,需要先做数据验证再决定是否投入。

如果你也在做类似的事情,我的建议是:先把工程底座打牢,再考虑花哨的功能。我见过太多项目在工程还没稳的时候就去搞智能路由,结果基础调用都不稳定,智能路由反而放大了问题。工程脱胎换骨这件事,急不得,但每一步都要踩实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询