1. 为什么要在 Spring Boot 里做 AI 应用平台
1.1 从“能跑通”到“能上线”之间隔着什么
我最早接触 Spring AI 是在一个内部知识库项目上,当时图省事,直接在 Controller 里 new 了一个 ChatClient,把 API Key 硬编码在代码里,调通那一刻确实挺爽。但等到要上线的时候问题全来了:模型调用超时没有降级策略、多轮对话的上下文存在内存里重启就丢、不同业务线要接不同模型却只能改代码重新打包、Token 消耗没有任何统计口径。这些问题单拎出来都不难,但堆在一起就说明一件事——Demo 和平台之间差的不是功能,是工程化。
所谓生产级 AI 应用平台,核心不是“能调通大模型”,而是把模型调用这件事变成一项可治理、可观测、可扩展的基础能力。它要解决的具体问题包括:统一接入多家模型供应商并支持热切换、管理对话会话与上下文窗口、编排 Agent 的工具调用链路、控制并发与限流、记录调用链路与成本、以及给上层业务提供稳定的 SDK 或 HTTP 接口。适合阅读这篇内容的读者,是已经写过 Spring Boot 业务、现在想把 AI 能力真正落到生产环境里的后端同学,也包括正在做技术选型的架构角色。
1.2 技术选型的几个关键取舍
选 Spring Boot 作为底座几乎是顺理成章的,团队现有的鉴权、监控、配置中心、数据库连接池这些基础设施都能直接复用,没必要为了 AI 单独起一套异构技术栈。真正需要斟酌的是 AI 编排层用什么。
| 方案 | 优势 | 代价 | 适用场景 |
|---|---|---|---|
| 直接调 HTTP API | 无额外依赖,完全可控 | 每家模型参数格式不同,重复代码多 | 只接一家模型的小项目 |
| Spring AI | 官方抽象,ChatClient/Embedding 统一 | 版本迭代快,部分高级特性滞后 | 主流选择,Java 团队首选 |
| LangChain4j | 生态丰富,Agent 支持成熟 | 与 Spring 集成需自己粘合 | 复杂 Agent 编排 |
| 自研编排层 | 完全贴合业务 | 维护成本高 | 有专门平台团队时 |
我最终选的是Spring AI 作为模型接入层 + 自研轻量编排层的组合。原因很实际:Spring AI 把 ChatModel、EmbeddingModel、VectorStore 这些概念抽象得很干净,切换模型供应商时业务代码基本不用动;但它的 Agent 编排能力在早期版本里还不够灵活,工具调用的重试、超时、并行策略我需要自己控制,所以在它之上包了一层编排逻辑。这个组合的好处是既吃到了官方抽象的红利,又保留了关键链路的控制权。
2. 平台分层架构与核心模块拆解
2.1 四层结构:接入层、编排层、能力层、治理层
一个能扛住生产流量的 AI 平台,我习惯把它拆成四层,每层职责单一,层与层之间通过接口通信,避免牵一发动全身。
接入层负责对外暴露能力,包括 REST 接口、SSE 流式响应、以及给内部服务用的 SDK。这一层只做参数校验、鉴权、限流,不掺业务逻辑。编排层是核心,负责把一次用户请求拆解成“检索上下文 → 组装 Prompt → 调用模型 → 解析工具调用 → 再调用模型”这样的链路,Agent 的循环逻辑就住在这里。能力层封装具体能力:对话、向量检索、工具执行、文档解析。治理层是横切关注点,包括模型路由、Token 计量、链路追踪、熔断降级、敏感内容过滤。
这样分层最直接的好处是,当我要把某个模型从 A 换成 B 时,只需要动治理层的路由配置,编排层和能力层完全无感。反过来,当业务要加一个新的工具(比如查订单),只需要在能力层注册一个 Tool,编排层自动就能用上。
2.2 模型路由:多供应商热切换的实现思路
生产环境不可能只依赖一家模型。一方面是可用性,某家服务抖动时要有备选;另一方面是成本,简单任务用便宜的小模型,复杂推理才上大模型。我的做法是在配置中心维护一张路由表:
ai: routing: rules: - name: simple-qa match: "intent == 'faq'" primary: qwen-turbo fallback: glm-4-flash - name: complex-reasoning match: "intent == 'analysis'" primary: qwen-max fallback: qwen-plus路由的匹配条件可以基于意图识别结果、用户等级、请求 Token 预估长度等。关键点是路由决策要在调用模型之前完成,而不是等失败了再重试,否则延迟会翻倍。降级策略我一般配两级:主模型超时或返回错误码时切备用模型,备用也失败才向上抛异常并返回兜底话术。
注意:路由表一定要支持运行时刷新,别写死在代码里。我踩过的坑是某次线上模型限流,改路由要重新发版,白白多扛了二十分钟的报错。
2.3 会话与上下文管理:滑动窗口不是唯一解
多轮对话的上下文管理是很多人第一个卡住的地方。最朴素的做法是把历史消息全带上,但 Token 会线性增长,成本和延迟都受不了。常见的策略是滑动窗口,只保留最近 N 轮对话。但纯滑动窗口有个明显缺陷:用户在第一轮说的关键信息(比如“我要查的是上个月的订单”)可能在第五轮就被挤掉了。
我的做法是滑动窗口 + 摘要压缩的组合。保留最近 K 轮原文,更早的历史用一个轻量模型压缩成一段摘要,拼在 System Prompt 里。这样既控制了 Token,又不丢关键信息。具体参数上,我一般设 K=6,摘要触发阈值是历史消息超过 10 轮。会话数据存 Redis,key 用session:{userId}:{conversationId},设置 2 小时过期,同时异步落库一份用于审计和数据分析。
public List<Message> buildContext(String conversationId, String userInput) { List<Message> recent = redis.getRecent(conversationId, 6); String summary = redis.getSummary(conversationId); List<Message> context = new ArrayList<>(); if (StringUtils.hasText(summary)) { context.add(new SystemMessage("历史对话摘要:" + summary)); } context.addAll(recent); context.add(new UserMessage(userInput)); return context; }3. Agent 编排与工具调用的落地细节
3.1 Agent 循环的本质:让模型决定下一步做什么
很多人把 Agent 想得很玄,其实剥开看就是一个循环:把可用工具的描述和用户问题一起给模型,模型返回“我要调用某个工具,参数是这些”,平台执行工具拿到结果,再把结果喂回模型,直到模型认为可以给出最终答案。这个循环的终止条件、最大轮次、超时控制,才是工程上的难点。
我实现的编排器核心逻辑大致是这样:设置最大循环轮次为 5,单轮工具执行超时 3 秒,整体请求超时 30 秒。每轮结束后检查模型返回是否包含工具调用意图,没有就直接返回文本结果。这里有个容易忽略的点——工具调用的参数要做校验和兜底,模型偶尔会生成格式不对的 JSON 或者不存在的参数名,直接反序列化会抛异常,必须捕获后把错误信息作为工具结果返回给模型,让它自己纠正。
3.2 工具注册:让新增能力零改动的设计
工具(Tool)的注册我用的是注解 + 自动扫描的方式。定义一个@AiTool注解,标注在 Spring Bean 的方法上,启动时扫描所有带注解的方法,把方法名、描述、参数 Schema 注册到工具注册表里。
@Component public class OrderTools { @AiTool(name = "queryOrder", description = "根据订单号查询订单状态") public OrderResult queryOrder( @ToolParam(description = "订单号") String orderNo) { return orderService.query(orderNo); } }这样业务同学要加一个新工具,只需要写一个方法加注解,不用改编排层的任何代码。工具描述一定要写清楚,因为模型就是靠这段描述来判断什么时候该调用它。我见过描述写得太模糊导致模型乱调工具的情况,比如把“查询天气”写成“获取信息”,模型就会在用户问订单的时候也去调它。
3.3 并发与限流:Agent 扛并发的真实瓶颈
Agent 场景下并发压力比普通接口大得多,因为一次用户请求可能触发 3 到 5 次模型调用。我做过压测,单实例在 4C8G 配置下,纯转发模型请求能扛住 200 QPS,但一旦带上 Agent 循环,实际能稳定支撑的用户并发只有 30 到 50。瓶颈不在 CPU,而在模型 API 的响应延迟和连接池。
应对手段有几个:一是给模型调用配置独立的线程池,和业务线程隔离,避免慢调用拖垮整个应用;二是对同一用户的请求做串行化,防止上下文错乱;三是在接入层做令牌桶限流,按用户维度和全局维度双重限制。线程池参数上,核心线程数我一般设成2 * CPU核数,队列用有界队列,拒绝策略用 CallerRuns 让调用方自己扛,形成天然的背压。
提示:别用无界队列。我早期用
LinkedBlockingQueue不设容量,结果高峰期任务堆积,内存直接飙到 OOM,排查了半天才发现是队列把请求全吞了。
4. 可观测性与成本治理的实操方案
4.1 链路追踪:一次请求到底调了几次模型
AI 应用的排查难度比普通接口高,因为链路是动态的,模型可能调了三次工具、四次模型。没有追踪能力基本没法定位问题。我的做法是给每次用户请求生成一个 traceId,贯穿整个编排过程,每次模型调用和工具调用都记录一条 span,包含耗时、输入 Token、输出 Token、模型名称、是否命中缓存。
这些数据落到 Elasticsearch 或者直接用日志平台,配合看板就能看到 P95 延迟、各模型调用占比、失败率。有一次线上反馈“回答变慢了”,我看板上一看,某个模型的 P99 从 2 秒涨到了 8 秒,切了路由立刻恢复,整个过程不到五分钟。
4.2 Token 计量与成本控制
Token 是要花钱的,不计量就等于烧钱不自知。我在治理层做了一个 Token 计量器,每次模型返回后从响应里取 usage 字段累加,按用户、按业务线、按天三个维度聚合。同时设置预算告警,某个业务线当天消耗超过阈值就发通知,超过硬上限就自动降级到便宜模型。
成本控制还有几个实用技巧:一是缓存高频问题的答案,用问题文本的向量做相似度匹配,命中就直接返回,省掉模型调用;二是Prompt 精简,System Prompt 别写太长,我见过有人把两千字的角色设定塞进去,每次调用都白花这笔钱;三是按需选择模型,意图识别、文本分类这类任务用小模型完全够用,没必要上大模型。
| 治理维度 | 采集指标 | 告警阈值建议 |
|---|---|---|
| 延迟 | P95/P99 模型调用耗时 | P99 > 5s |
| 成本 | 单用户日 Token 消耗 | 超均值 3 倍 |
| 可用性 | 模型调用失败率 | 5 分钟窗口 > 3% |
| 质量 | 兜底话术触发率 | > 5% |
4.3 敏感内容过滤与安全兜底
平台对外提供服务,输入输出的内容安全必须管起来。我的做法是在接入层做输入过滤,在返回前做输出过滤,中间加一层基于关键词和向量相似度的检测。输入侧主要防注入类攻击,比如用户试图通过特殊 Prompt 让模型泄露 System Prompt;输出侧主要防不当内容。检测命中后不是简单报错,而是返回一个预设的友好话术,同时记录一条审计日志。
这里有个经验:过滤规则要可配置、可热更新,因为对抗手段在变,规则库需要持续调整。我把规则存在数据库里,配一个管理后台,运营同学可以自己加词、调阈值,不用每次都找开发。
5. 常见问题排查与踩坑记录
5.1 流式响应中断与 SSE 连接管理
流式输出是 AI 应用的标配体验,但 SSE 连接比普通 HTTP 脆弱得多。常见问题是客户端断开后服务端还在往模型拉数据,白白消耗资源。解决办法是监听连接关闭事件,一旦客户端断开就取消上游的模型调用。Spring 里可以通过SseEmitter的onCompletion和onTimeout回调来处理,配合Disposable取消订阅。
另一个坑是代理层缓冲。有些网关默认会缓冲响应体,导致流式效果失效,用户要等全部生成完才看到内容。部署时一定要确认网关关闭了对 SSE 路径的缓冲,并设置足够长的读超时。
5.2 模型返回格式不稳定的处理
即使开了 JSON 模式,模型偶尔还是会返回带 Markdown 代码块包裹的 JSON,或者多一句“好的,以下是结果”。直接解析必挂。我的处理方式是写一个健壮的解析器:先尝试直接解析,失败则用正则提取第一个{...}或[...]片段再解析,再失败就触发一次重试,重试时在 Prompt 里强调“只返回 JSON,不要任何其他文字”。重试两次还失败就降级返回兜底结果,并记录一条异常日志用于后续分析。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 回答重复或答非所问 | 上下文拼接错误 | 检查会话 key 是否串号 |
| 工具调用不触发 | 工具描述不清晰 | 优化 description 文案 |
| 流式输出卡顿 | 网关缓冲 | 关闭代理缓冲 |
| Token 消耗异常高 | 历史未压缩 | 检查摘要触发逻辑 |
| 偶发超时 | 模型侧抖动 | 看路由降级是否生效 |
5.4 我踩过的几个真实坑
第一个坑是把 API Key 写进了配置文件提交到仓库。虽然后来紧急轮换了密钥,但这个教训让我养成了所有密钥走环境变量或密钥管理服务的习惯。第二个坑是没有给模型调用设超时,默认超时可能是无限等待,某次模型服务卡住,线程池被占满,整个应用雪崩。第三个坑是会话数据只存内存,本地测试没问题,一上多实例部署就出现“同一个用户两次请求上下文对不上”,因为负载均衡打到了不同实例。这三个坑本质上都是把 Demo 思维带进了生产环境,值得每个刚上手的人警惕。
6. 从零搭建的最小可行路径
6.1 依赖引入与基础配置
如果现在让我从零搭一个最小可用的版本,我会这样起步。先在pom.xml里引入 Spring AI 的 starter,注意版本要和 Spring Boot 版本对齐,我一般用 Spring Boot 3.2.x 配 Spring AI 1.0.x 这条线。然后配置模型连接信息,全部走环境变量:
spring: ai: dashscope: api-key: ${AI_API_KEY} chat: options: model: qwen-plus temperature: 0.7temperature这个参数值得说一下,它控制输出的随机性。做客服问答我一般设 0.3 到 0.5,保证回答稳定;做创意生成才调到 0.8 以上。别小看这个参数,设错了要么回答死板要么胡言乱语。
6.2 第一个可用的对话接口
最小版本不需要 Agent,先把对话跑通。写一个 Service 注入ChatClient,在方法里组装消息列表调用即可。关键是把会话 ID 作为参数传进来,从 Redis 取历史,调用完再把新消息写回去。这一步做完,你就有了一个支持多轮对话的基础服务。
@Service public class ChatService { private final ChatClient chatClient; private final SessionStore sessionStore; public String chat(String sessionId, String input) { List<Message> context = sessionStore.load(sessionId); context.add(new UserMessage(input)); String reply = chatClient.prompt() .messages(context) .call() .content(); sessionStore.append(sessionId, input, reply); return reply; } }6.3 逐步演进:从对话到 Agent 到平台
有了对话能力之后,演进路径我建议分三步走。第一步加工具调用,把业务系统里高频的查询操作注册成 Tool,让模型能主动调用;第二步加治理能力,把 Token 计量、链路追踪、限流降级补上,这一步是能不能上生产的分水岭;第三步做多租户和配置化,让不同业务线能各自配置模型、Prompt、工具集,平台化才算成型。
每一步都不要跳。我见过团队一上来就想做全功能平台,结果基础对话的上下文管理都没做扎实,后面全是返工。先把一条链路做透,再横向扩展,这个节奏最稳。
6.4 部署与容量规划的一点经验
部署上,AI 应用对内存的要求比普通 Web 应用高,因为要缓存会话、向量数据、模型客户端连接。我一般给单实例配 4G 起步,JVM 堆设 2G,剩下的留给堆外和系统。实例数按峰值并发除以单实例承载量来算,再留 30% 余量。容器化部署时注意健康检查要包含模型连通性探测,别等流量进来了才发现模型连不上。
容量规划有个简单公式可以参考:单实例支撑并发数 ≈ 模型平均响应时间(秒)的倒数 × 线程池大小 × 0.7。比如模型平均响应 2 秒,线程池 50,那单实例大约能扛 17 个并发。这个数字很粗糙,但用来做初始估算够用了,真实容量还是要靠压测校准。
最后分享一个我在实际运维中体会很深的点:AI 平台的稳定性不取决于你接了多少家模型,而取决于你对失败路径的处理有多细致。模型超时怎么办、返回格式错了怎么办、工具执行抛异常怎么办、上下文超长了怎么办,把这些边界情况一个个想清楚、测到位,平台才算真正立得住。我现在的习惯是每加一个新能力,先写它的失败用例,再写正常用例,这个顺序能帮我提前发现大部分隐患。