Java大模型网关工程化实战:Spring Boot+MyBatis+Maven从脚本到生产

发布时间:2026/10/7 16:31:45
Java大模型网关工程化实战:Spring Boot+MyBatis+Maven从脚本到生产 1. 从单体服务到网关层为什么我要给大模型调用做一次工程脱胎换骨去年下半年开始团队里接入大模型的项目越来越多。最开始大家各写各的A项目直接调某家APIB项目又封装了一套自己的重试逻辑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: /gatewaymax-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在网关项目里的取舍为什么不用JPA4.1 网关的数据库访问特征决定了ORM选型网关的数据库访问有两个特点读多写少且查询模式相对固定。读的是配置和配额写的是调用日志和计量数据。这种场景下MyBatis比JPA更合适。JPA的优势在于对象关系映射的自动化适合领域模型复杂的业务系统。但网关的配置表结构很简单就是几张扁平的表用JPA的自动映射反而增加了不确定性——你永远不知道Hibernate会生成什么样的SQL。MyBatis的SQL是手写的执行计划可控这对延迟敏感的网关来说很重要。另一个原因是MyBatis对批量操作的支持更直接。网关的调用日志是高频写入用MyBatis的foreach批量插入比JPA的saveAll更可控。insert idbatchInsertLogs parameterTypejava.util.List INSERT INTO llm_call_log (request_id, tenant_id, model_name, prompt_tokens, completion_tokens, latency_ms, success, created_at) VALUES foreach collectionlist itemitem separator, (#{item.requestId}, #{item.tenantId}, #{item.modelName}, #{item.promptTokens}, #{item.completionTokens}, #{item.latencyMs}, #{item.success}, NOW()) /foreach /insert4.2 二级缓存的正确打开方式MyBatis的二级缓存是个争议很大的特性。默认是关闭的因为它的缓存粒度是Mapper级别多个Mapper操作同一张表时容易出现脏数据。但在网关场景下配置表的读频率极高、写频率极低二级缓存其实很合适。我的做法是只对配置查询开启二级缓存并且配置flushInterval为60秒cache evictionLRU flushInterval60000 size512 readOnlytrue/readOnlytrue很关键它告诉MyBatis缓存的对象不会被修改可以安全地共享引用。如果设成falseMyBatis每次返回缓存对象时都会做一次序列化拷贝性能反而下降。但这里有个坑二级缓存是基于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。排查依赖冲突的标准流程是mvn dependency:tree打印完整依赖树搜索冲突的artifactId看哪些路径引入了不同版本用exclusions排除掉不需要的传递依赖在dependencyManagement里统一版本mvn dependency:tree -Dincludescom.squareup.okhttp3:okhttp这个命令只打印okhttp相关的依赖路径比看完整依赖树高效得多。我建议把常用的排查命令做成alias省得每次敲。5.2 用dependencyManagement统一版本多模块项目里版本管理必须集中。我的做法是在父POM的dependencyManagement里声明所有关键依赖的版本子模块引用时不写版本号。dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version3.2.0/version typepom/type scopeimport/scope /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency /dependencies /dependencyManagementspring-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.skiptrue注意这两个的区别前者编译测试代码但不运行后者连编译都跳过。第三配置国内镜像仓库。这个不用多说下载依赖的速度差异是数量级的。mirror idaliyun/id mirrorOfcentral/mirrorOf urlhttps://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 AtomicReferenceGatewayConfig 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延迟320ms45msP99延迟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境做核心思路是维护每个供应商的实时健康分路由时按分数加权随机。语义缓存则要谨慎因为大模型的输出有随机性缓存命中率可能不高需要先做数据验证再决定是否投入。如果你也在做类似的事情我的建议是先把工程底座打牢再考虑花哨的功能。我见过太多项目在工程还没稳的时候就去搞智能路由结果基础调用都不稳定智能路由反而放大了问题。工程脱胎换骨这件事急不得但每一步都要踩实。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询