1. 为什么我选择用 Spring Boot 来搭 AI 应用平台
1.1 从一次真实的踩坑经历说起
去年下半年,我接手了一个内部 AI 工具平台的重构工作。原来的系统是用 Python 写的,模型调用、业务逻辑、用户管理全揉在一个项目里,代码量上来之后,改一个接口要动三四个文件,部署的时候还得单独维护一套 Python 环境。最要命的是,团队里大部分后端同学都是 Java 技术栈出身,每次排查问题都要跨语言调试,效率低得让人抓狂。
后来我下定决心,把整个平台用 Spring Boot 重写了一遍。选它的理由很直接:团队熟悉、生态成熟、工程化能力强。更重要的是,Spring AI 这个项目已经把主流大模型的调用封装得相当到位,不需要自己从零造轮子。重写完之后,接口响应稳定了,部署也简化成一条命令,运维同学终于不用再问我“这个 Python 脚本到底跑在哪个虚拟环境里”了。
这篇文章就是把这套平台的搭建思路、核心模块设计、踩过的坑和实操细节完整地分享出来。不管你是刚接触 AI 应用开发的 Java 后端,还是想把现有 AI 能力集成到业务系统里的架构师,都能从里面找到可以直接参考的东西。
1.2 生产级 AI 平台到底要解决什么问题
很多人一提到“AI 应用平台”,第一反应就是“调个模型接口返回结果”。但真正放到生产环境里,事情远没有这么简单。我总结下来,一个能扛住真实业务流量的 AI 平台,至少要解决下面这几类问题:
- 模型接入的统一抽象:不同厂商的模型接口协议、参数命名、返回格式都不一样,如果每个业务模块都直接调原始 SDK,后期换模型就是灾难。
- 对话上下文的管理:多轮对话需要维护会话状态,还要控制 token 消耗,不能无限制地把历史消息全塞进去。
- Agent 能力的编排:单纯的问答满足不了复杂场景,需要让模型能调用工具、查数据库、执行多步推理。
- 并发与限流:模型调用是典型的慢接口,一个请求可能跑十几秒,高并发下线程池、超时、重试策略都得设计好。
- 可观测性:调用量、耗时、token 消耗、失败率,这些指标不监控起来,出了问题根本无从下手。
- 安全与权限:谁能调用哪个模型、单次请求的 token 上限、敏感内容的过滤,都是必须考虑的。
Spring Boot 在这几个方面都有现成的解决方案:Spring AI 负责模型抽象,Spring Security 管权限,Micrometer 做指标采集,Resilience4j 处理熔断限流。把这些组件组合起来,就能搭出一个结构清晰、易于维护的平台。
1.3 整体技术选型与架构分层
在动手之前,我先把技术栈定下来。选型的原则是:优先用 Spring 官方生态,减少第三方依赖带来的不确定性。
| 层次 | 技术选型 | 选型理由 |
|---|---|---|
| 基础框架 | Spring Boot 3.2.x | 支持虚拟线程,对高并发场景友好 |
| 模型接入 | Spring AI 1.0.x | 官方抽象,支持多种模型提供商 |
| 数据持久化 | MyBatis-Plus + MySQL | 团队熟悉,CRUD 效率高 |
| 缓存 | Redis | 存会话上下文、限流计数器 |
| 接口文档 | SpringDoc OpenAPI | 自动生成,方便前端对接 |
| 监控 | Micrometer + Prometheus | 指标采集标准化 |
| 熔断限流 | Resilience4j | 轻量,配置灵活 |
架构上我分成了四层:接入层负责 HTTP 接口和参数校验,编排层处理对话逻辑和 Agent 调度,模型层封装具体的模型调用,基础设施层提供缓存、持久化、监控等支撑。这样分层的好处是,换模型只动模型层,改业务逻辑只动编排层,互不影响。
2. 核心模块的细节拆解与实操要点
2.1 模型接入层:用 Spring AI 做统一抽象
Spring AI 最核心的价值在于它提供了一套统一的ChatClient和ChatModel接口。不管你底层用的是哪家的模型,上层业务代码调用的方式都是一样的。我实际用下来,这个抽象层设计得比较合理,切换模型提供商只需要改配置,不用动业务代码。
配置一个模型的基本写法是这样的:
spring: ai: openai: api-key: ${MODEL_API_KEY} base-url: ${MODEL_BASE_URL} chat: options: model: ${MODEL_NAME} temperature: 0.7 max-tokens: 2048然后在代码里注入ChatClient就能用:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个专业的技术助手") .build(); } public String chat(String message) { return chatClient.prompt() .user(message) .call() .content(); } }这里有个细节要注意:temperature这个参数不是随便设的。做代码生成、数学推理这类需要确定性的任务,我一般设 0.1 到 0.3;做创意文案、头脑风暴,设 0.7 到 0.9。设太高会出现胡言乱语,设太低回答又会很死板。max-tokens也要根据实际场景控制,设太大不仅浪费钱,还可能因为生成时间过长导致接口超时。
提示:不同模型对参数的支持程度不一样,有些模型不支持
temperature或者top-p,配置前最好查一下对应提供商的文档,避免参数被静默忽略。
2.2 会话上下文管理:别把历史消息一股脑塞进去
多轮对话是 AI 应用的基本能力,但上下文管理是个容易被忽视的坑。我见过有项目把用户所有的历史消息都拼到 prompt 里,结果没聊几轮就超出模型的上下文窗口,直接报错。
我的做法是在 Redis 里维护每个会话的消息列表,同时做两层控制:
第一层是消息数量限制,默认保留最近 10 轮对话。第二层是token 预算控制,每次组装 prompt 之前先估算 token 数,超过阈值就从最早的消息开始丢弃。估算 token 有个粗略的经验公式:英文大约 4 个字符 1 个 token,中文大约 1.5 个字符 1 个 token。精确计算可以用对应模型的 tokenizer,但生产环境里用估算值加一点余量就够了。
public List<Message> buildContext(String sessionId, String newMessage) { List<Message> history = redisTemplate.opsForList() .range("chat:session:" + sessionId, 0, -1); List<Message> context = new ArrayList<>(); int tokenBudget = 3000; int used = estimateTokens(newMessage); // 从最新消息往前取,直到超出预算 for (int i = history.size() - 1; i >= 0; i--) { Message msg = history.get(i); int cost = estimateTokens(msg.getContent()); if (used + cost > tokenBudget) break; context.add(0, msg); used += cost; } context.add(new UserMessage(newMessage)); return context; }会话数据我设置了 2 小时的过期时间,避免 Redis 内存无限增长。如果业务需要长期保存对话记录,那就异步落库到 MySQL,Redis 只做热数据的缓存。
2.3 Agent 编排:让模型学会调用工具
Agent 是这两年最火的概念之一,但很多人对它的理解停留在“能自动干活的 AI”。从工程角度看,Agent 的本质是让模型能够决定调用哪个工具、传什么参数、拿到结果后如何继续推理。Spring AI 提供了@Tool注解来声明可调用的工具方法,用起来相当顺手。
@Component public class WeatherTools { @Tool(description = "查询指定城市的实时天气") public String getWeather( @ToolParam(description = "城市名称,如北京") String city) { // 实际调用天气 API return weatherApi.query(city); } }然后在 ChatClient 里注册这些工具:
ChatClient client = builder .defaultTools(new WeatherTools(), new DatabaseTools()) .build();模型在推理过程中如果判断需要查天气,就会自动生成工具调用请求,Spring AI 负责执行并把结果回传给模型继续推理。这个循环会一直持续到模型给出最终答案。
这里有几个实操经验值得分享。第一,工具描述要写清楚,模型是根据 description 来判断该不该调用这个工具的,描述模糊会导致误调用或者该调不调。第二,工具方法要幂等,因为模型可能会重复调用同一个工具。第三,要设置最大循环次数,防止模型陷入死循环,我一般设 5 次,超过就强制返回当前结果。
2.4 并发处理:虚拟线程真的能救场
模型调用是 IO 密集型操作,一个请求动辄几秒到几十秒。传统的线程池模型下,每个请求占一个线程,并发量一上来线程池就满了,后面的请求只能排队。Spring Boot 3.2 引入的虚拟线程正好解决这个问题。
开启虚拟线程很简单,一行配置:
spring: threads: virtual: enabled: true开启之后,Tomcat 的请求处理会自动使用虚拟线程。虚拟线程的创建成本极低,可以轻松创建几十万个,IO 阻塞时会自动让出底层载体线程,吞吐量提升非常明显。我实测下来,同样的硬件配置,开启虚拟线程后并发处理能力提升了 3 到 5 倍。
不过要注意,虚拟线程不适合 CPU 密集型任务,如果你的业务里有大量计算逻辑,还是得用平台线程池。另外,使用虚拟线程时要避免使用synchronized块,因为它会导致载体线程被固定,失去虚拟线程的优势,改用ReentrantLock更合适。
2.5 限流与熔断:保护自己也要保护下游
模型服务再稳定也有抽风的时候,限流和熔断是生产环境的必备能力。我用 Resilience4j 做了两层防护。
第一层是接口级限流,防止单个用户或 IP 疯狂刷接口。用令牌桶算法,每个用户每秒最多 5 次请求:
RateLimiter rateLimiter = RateLimiter.of("userLimit", RateLimiterConfig.custom() .limitForPeriod(5) .limitRefreshPeriod(Duration.ofSeconds(1)) .timeoutDuration(Duration.ZERO) .build());第二层是模型调用熔断,当某个模型的失败率超过阈值时,自动切断调用,走降级逻辑。比如连续 10 次调用有 5 次失败,就熔断 30 秒,期间所有请求直接返回兜底话术,避免雪崩。
CircuitBreaker circuitBreaker = CircuitBreaker.of("modelCall", CircuitBreakerConfig.custom() .failureRateThreshold(50) .slidingWindowSize(10) .waitDurationInOpenState(Duration.ofSeconds(30)) .build());降级策略我一般准备两套:一是切换到备用模型,二是返回预设的友好提示。具体用哪套看业务重要性,核心业务切备用模型,非核心业务直接降级。
3. 完整实操流程与关键环节实现
3.1 项目初始化与依赖配置
先把项目骨架搭起来。我用 Spring Initializr 生成基础结构,选 Spring Boot 3.2.x、Java 21、Maven 构建。核心依赖在pom.xml里配置:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M4</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot3</artifactId> <version>2.2.0</version> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> </dependencies>Spring AI 的版本迭代比较快,写这篇文章时用的是 1.0.0-M4,正式版发布后 API 可能有调整,升级前记得看 release notes。
3.2 数据库表结构设计
平台的核心表我设计了四张:用户表、会话表、消息表、模型配置表。这里重点说会话表和消息表,因为它们是上下文管理的基础。
CREATE TABLE chat_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(64) NOT NULL UNIQUE, user_id BIGINT NOT NULL, title VARCHAR(128), model_name VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_id (user_id) ); CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL, content TEXT, token_count INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_session_id (session_id) );token_count这个字段很有用,可以用来统计每个会话的 token 消耗,做成本核算。role字段存user、assistant、system三种值,对应消息的不同角色。
3.3 核心对话接口的实现
对话接口是整个平台最核心的部分,我把它拆成了几个步骤:参数校验、限流检查、上下文组装、模型调用、结果保存、指标上报。
@RestController @RequestMapping("/api/chat") public class ChatController { @PostMapping("/completions") public SseEmitter chat(@RequestBody @Valid ChatRequest request) { // 1. 限流检查 if (!rateLimiter.tryAcquire(request.getUserId())) { throw new BusinessException("请求过于频繁,请稍后再试"); } // 2. 创建 SSE 连接,支持流式返回 SseEmitter emitter = new SseEmitter(60_000L); // 3. 异步处理 CompletableFuture.runAsync(() -> { try { chatService.streamChat(request, emitter); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }这里用了 SSE(Server-Sent Events)做流式返回,用户体验比等完整结果好很多。模型生成一个字就推一个字,前端可以边收边显示。SSE 的超时时间我设了 60 秒,因为有些复杂问题模型确实要跑很久。
流式处理的核心代码:
public void streamChat(ChatRequest request, SseEmitter emitter) { List<Message> context = contextManager.buildContext( request.getSessionId(), request.getMessage()); chatClient.prompt() .messages(context) .stream() .content() .subscribe( chunk -> { try { emitter.send(SseEmitter.event() .data(chunk)); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, () -> { // 保存完整消息到数据库 saveMessage(request.getSessionId(), fullContent); emitter.complete(); } ); }3.4 监控指标埋点
没有监控的线上系统就是裸奔。我用 Micrometer 埋了几个关键指标:请求总数、请求耗时、token 消耗量、失败率。
@Component public class ChatMetrics { private final Counter requestCounter; private final Timer requestTimer; private final Counter tokenCounter; public ChatMetrics(MeterRegistry registry) { this.requestCounter = Counter.builder("ai.chat.requests") .description("对话请求总数") .register(registry); this.requestTimer = Timer.builder("ai.chat.duration") .description("对话请求耗时") .register(registry); this.tokenCounter = Counter.builder("ai.chat.tokens") .description("token 消耗总量") .register(registry); } public void recordRequest(long durationMs, int tokens, boolean success) { requestCounter.increment(); requestTimer.record(durationMs, TimeUnit.MILLISECONDS); tokenCounter.increment(tokens); if (!success) { failureCounter.increment(); } } }这些指标通过/actuator/prometheus端点暴露出来,Prometheus 定时抓取,Grafana 做可视化。我配了几个告警规则:失败率超过 10% 告警、P99 耗时超过 30 秒告警、token 消耗突增告警。有了这些,出问题能第一时间发现。
3.5 部署与配置管理
生产环境部署我用的是 Docker + Docker Compose,把应用、MySQL、Redis、Prometheus 都编排在一起。关键配置通过环境变量注入,敏感信息不写死在代码里。
version: '3.8' services: app: build: . ports: - "8080:8080" environment: - MODEL_API_KEY=${MODEL_API_KEY} - MODEL_BASE_URL=${MODEL_BASE_URL} - SPRING_PROFILES_ACTIVE=prod depends_on: - mysql - redis deploy: resources: limits: memory: 2GJVM 参数我调过几轮,最终用的是:
java -XX:+UseZGC -Xms1g -Xmx2g -XX:MaxMetaspaceSize=256m -jar app.jarZGC 的停顿时间在毫秒级,对响应时间敏感的 AI 接口很友好。堆内存设 2G 是因为要缓存一些模型配置和会话数据,太小会频繁 GC,太大又浪费资源。
4. 常见问题与排查技巧实录
4.1 模型调用超时怎么办
这是最常见的问题。模型服务本身响应慢,或者网络抖动,都会导致超时。我的处理策略是分三层:
第一层是客户端超时设置,Spring AI 默认的超时时间偏短,我一般调到 60 秒。第二层是重试机制,对超时和 5xx 错误自动重试 2 次,重试间隔用指数退避。第三层是降级兜底,重试都失败就返回友好提示,同时记录日志告警。
@Retry(name = "modelRetry", fallbackMethod = "fallback") public String callModel(String prompt) { return chatClient.prompt().user(prompt).call().content(); } public String fallback(String prompt, Exception e) { log.error("模型调用失败", e); return "抱歉,服务暂时不可用,请稍后再试"; }重试配置:
resilience4j: retry: instances: modelRetry: max-attempts: 3 wait-duration: 1s enable-exponential-backoff: true exponential-backoff-multiplier: 24.2 上下文丢失或错乱
多轮对话里,用户经常反馈“它怎么忘了刚才说的话”。排查下来通常是几个原因:会话 ID 生成有问题、Redis 数据过期、并发写入覆盖。
我的排查步骤是这样的:先看 Redis 里对应 sessionId 的 key 存不存在,不存在就是过期或没写进去;存在的话看消息顺序对不对,顺序乱了就是并发写入的问题。并发写入的解决办法是给同一个会话的写操作加分布式锁,用 Redis 的SETNX实现,锁的粒度是 sessionId。
public void saveMessage(String sessionId, Message message) { String lockKey = "lock:session:" + sessionId; String lockValue = UUID.randomUUID().toString(); try { Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 执行写入 doSaveMessage(sessionId, message); } } finally { // 释放锁,用 Lua 脚本保证原子性 releaseLock(lockKey, lockValue); } }4.3 Token 消耗异常增长
有段时间我发现 token 消耗量突然涨了好几倍,查下来是某个业务方把max-tokens设成了 8192,而且没做上下文裁剪。解决办法是在平台层面做硬性限制,不管业务方怎么配,单次请求的 token 上限不超过 4096,上下文预算不超过 3000。
另外建议做一个 token 消耗的日报,按用户、按业务线统计,发现异常能及时定位。我用的方案是每天凌晨跑一个定时任务,从消息表里聚合前一天的 token 消耗,生成报表发到群里。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 接口返回 401 | API Key 失效或配置错误 | 检查环境变量和配置文件 | 更新 Key,重启服务 |
| 响应特别慢 | 模型服务负载高或网络问题 | 看监控的 P99 耗时 | 切备用模型,加超时限制 |
| 回答内容重复 | temperature 设太低 | 检查模型参数配置 | 调高 temperature 到 0.7 以上 |
| 上下文丢失 | Redis 过期或并发覆盖 | 查 Redis key 和写入日志 | 加分布式锁,调整过期时间 |
| 内存溢出 | 会话数据堆积 | 看堆内存和 GC 日志 | 加过期策略,限制单会话消息数 |
| 流式返回中断 | SSE 超时或网络断开 | 看客户端和服务端日志 | 调大超时,加心跳保活 |
4.5 几个我踩过的坑
第一个坑是虚拟线程和 synchronized 的冲突。我一开始在会话保存的方法上加了synchronized,结果发现虚拟线程的优势完全没发挥出来,吞吐量跟平台线程差不多。后来改成ReentrantLock才正常。这个坑很隐蔽,因为代码能跑,只是性能上不去,不看监控根本发现不了。
第二个坑是 Spring AI 的版本兼容性。有次升级到新版本,发现ChatClient的 API 变了,编译直接报错。后来我养成了习惯,升级前先看官方 migration guide,小版本升级也要跑一遍集成测试。
第三个坑是模型返回的 JSON 解析失败。让模型返回结构化数据时,它有时候会在 JSON 外面包一层 markdown 代码块标记,直接解析就报错。解决办法是在解析前先做清洗,把json 和这些标记去掉,或者用更宽松的解析器。
第四个坑是限流配置太严。我一开始把单用户限流设成每秒 2 次,结果正常用户连续问几个问题就被限了,体验很差。后来改成每秒 5 次,突发允许 10 次,才比较合理。限流阈值一定要根据真实用户行为来定,拍脑袋设的数字往往不合适。
5. 平台后续可以怎么扩展
这套平台跑了大半年,整体比较稳定。如果继续往下做,我觉得有几个方向值得投入。
多模型路由是第一个。现在平台支持配置多个模型,但调用哪个是写死的。可以做一个智能路由,根据问题类型、成本预算、当前各模型的负载情况动态选择。比如简单问题走便宜的小模型,复杂推理走能力强的大模型,能省不少成本。
RAG 知识库是第二个。现在平台只能靠模型自身的知识回答,接入企业私有知识库后,能回答很多专业领域的问题。Spring AI 已经提供了 VectorStore 的抽象,对接向量数据库不难,难的是文档切分和召回策略的调优。
Agent 工作流编排是第三个。现在的 Agent 还是单轮的,复杂任务需要多步协作。可以引入工作流引擎,把多个 Agent 串起来,每个负责一个子任务,最后汇总结果。这个方向工程复杂度比较高,但价值也大。
成本核算与配额管理是第四个。现在 token 消耗只是统计,还没有跟用户或部门挂钩。可以做一个配额系统,每个用户每月有固定的 token 额度,用完就限流,这样能有效控制成本。
我在实际运维这套平台的过程中最大的体会是:AI 应用的工程复杂度,八成不在模型本身,而在周边的工程设施。模型调用就那几行代码,但要让它在生产环境稳定运行,需要处理的边界情况、异常场景、性能问题,比传统 CRUD 应用多得多。把监控做扎实,把降级做完善,把配置管理做规范,这些看起来不起眼的工作,才是平台能不能扛住真实流量的关键。