
Java 生态里做 AI 应用这件事这两年从“观望”迅速变成了“真香”。以前要在 Spring Boot 项目里接一个大模型能力得自己写 HTTP 客户端、拼 JSON、处理流式返回、管理重试和超时一套下来光是胶水代码就够喝一壶。Spring AI 出现之后这件事的门槛被拉低了一个数量级——它把大模型交互抽象成了类似 JdbcTemplate 那样的模板化调用用 ChatClient 就能完成对话、流式输出、结构化映射。这篇内容就是围绕“构建第一个 Java AI 应用”这个目标把从环境准备到跑通第一个对话接口的完整链路拆开讲清楚顺带把我在实际接入过程中踩过的坑和验证过的配置一并分享出来。适合有 Java 和 Spring Boot 基础、想快速把 AI 能力落到自己项目里的开发者也适合正在准备 Java 面试、想补一块“AI 集成”实战经验的朋友。1. 为什么是 Spring AI而不是自己手写 HTTP 调用1.1 手写调用的真实成本被严重低估很多人第一反应是调个大模型接口而已RestTemplate 或者 WebClient 发个 POST 不就完了我一开始也是这么想的直到真正把代码写进生产环境才发现问题远不止“发请求”这么简单。一个能用的对话功能背后至少要处理这些事请求体的消息结构组装system、user、assistant 三种角色的顺序和拼接规则、流式响应的 SSE 解析、超时与重试策略、异常分类限流、内容审核拦截、网络抖动是完全不同的处理方式、多轮对话的上下文维护、以及不同模型厂商之间接口字段的差异适配。这些逻辑如果每个项目都手写一遍代码重复率高得离谱而且一旦换模型供应商改动面会非常大。Spring AI 的价值就在于它把这些共性逻辑收敛成了一套统一的抽象层你面向的是ChatClient和Prompt这样的接口底层换模型只需要改配置业务代码基本不动。这跟当年 Spring 用 JdbcTemplate 统一数据库访问是一个思路——不是不能手写而是没必要重复造轮子。1.2 ChatClient 的定位对话场景的“模板方法”ChatClient是 Spring AI 里最核心的门面类它的设计哲学是 fluent API 加链式调用。你可以把它理解成一个专门为对话场景优化的模板方法prompt()开始构建请求user()或system()填充消息call()发起同步调用stream()发起流式调用最后content()拿到纯文本结果。整个链路读起来接近自然语言可读性比一堆 builder 拼装强很多。它和底层的ChatModel是分层关系。ChatModel是更底层的模型抽象负责真正和供应商 API 打交道ChatClient是构建在ChatModel之上的高层封装负责消息构建、默认值注入、结果转换。日常业务开发用ChatClient就够了只有在需要精细控制请求参数时才下沉到ChatModel。1.3 版本选择为什么建议从稳定版起步Spring AI 的版本迭代速度比较快早期版本和现在的 API 差异不小。我的建议是新项目直接选当前稳定版不要追最新的里程碑版本。原因很实际——里程碑版本经常有 API 破坏性变更你今天写的ChatClient调用方式下个版本可能就改了方法签名对于要长期维护的项目来说这是额外的维护负担。选版本时还要注意和 Spring Boot 版本的对应关系。Spring AI 对 Spring Boot 的依赖比较敏感尤其是自动配置相关的模块。如果你用的是 Spring Boot 3.x那 Spring AI 也要选支持 3.x 的版本线混用容易出现自动配置不生效、Bean 注入失败这类问题。这一点在引入依赖之前一定要去官方文档的兼容性说明里确认一遍别嫌麻烦。2. 环境准备阶段最容易翻车的几个细节2.1 JDK 版本不是随便选的Spring AI 基于 Spring Boot 3.x而 Spring Boot 3.x 强制要求 JDK 17 及以上。这不是建议是硬性要求——Spring Boot 3 用了大量 JDK 17 的新特性比如 record、密封类、模式匹配低版本 JDK 直接编译不过。我见过有人用 JDK 8 建项目依赖一引入就报一堆找不到符号的错误排查半天才发现是 JDK 版本问题。所以第一步先确认你的 JDK 版本java -version输出里如果看到17、21这样的版本号就没问题。如果还是 8 或者 11先去装一个新版本并且把JAVA_HOME指向新版本。这里有个容易忽略的点即使你系统里装了 JDK 17如果JAVA_HOME还指向旧的 JDK 8Maven 编译时用的还是旧版本。所以改完安装目录后务必再执行一次java -version和mvn -version双重确认。2.2 构建工具的依赖管理要统一Spring AI 的依赖建议通过 BOMBill of Materials方式引入也就是先导入spring-ai-bom再引入具体模块时不写版本号。这样做的好处是各个 Spring AI 模块之间的版本自动对齐避免出现spring-ai-core和spring-ai-openai版本不一致导致的类冲突。Maven 的写法是在dependencyManagement里导入 BOMdependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version你的版本号/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement然后在dependencies里引入具体模块不写版本dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency注意Spring AI 的 starter 命名在不同版本里有过调整早期是spring-ai-openai-spring-boot-starter后来统一成了spring-ai-starter-model-xxx这种格式。引入之前先确认你用的版本对应哪种命名写错了会直接报依赖找不到。2.3 密钥配置千万别硬编码调用大模型需要 API Key这个 Key 绝对不能写死在代码里然后提交到代码仓库。正确做法是通过配置文件加环境变量注入。在application.yml里这样写spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL:https://api.openai.com} chat: options: model: gpt-4o-mini temperature: 0.7${OPENAI_API_KEY}这种写法表示从环境变量读取冒号后面的是默认值。这样本地开发时在 IDE 的运行配置里设置环境变量部署到服务器时用系统的环境变量或者配置中心注入代码仓库里永远不出现真实密钥。我踩过的一个坑是在 IDE 里设置了环境变量但用的是 IDE 自带的终端跑 Maven 命令结果终端里读不到 IDE 配置的环境变量启动就报 Key 为空。后来统一改成在系统层面设置环境变量或者用.env文件配合启动脚本加载才彻底解决。3. 从零写出第一个可运行的对话接口3.1 项目骨架的搭建顺序建项目这件事顺序很重要。我的习惯是先建一个最干净的 Spring Boot 骨架确认能正常启动再逐步加 Spring AI 依赖。这样一旦出问题能快速定位是骨架本身的问题还是 AI 依赖引入的问题。如果一上来就把所有依赖堆进去启动失败时排查范围会大很多。用 Spring Initializr 建项目时先只勾选 Spring Web 就够了Spring AI 的依赖后面手动加。建好后先跑一次mvn spring-boot:run看到启动日志里出现Started Application就说明骨架没问题。3.2 自动配置帮你做了什么引入 Spring AI 的 starter 之后它会通过自动配置机制帮你创建好ChatModel和ChatClient.Builder这两个 Bean。你不需要自己 new直接在需要的地方注入就行。这背后的逻辑是starter 里有一个自动配置类它会读取application.yml里spring.ai.openai开头的配置用这些配置构造出OpenAiChatModel再包装成ChatClient.Builder注册到容器里。理解这一点很关键因为当你发现注入ChatClient.Builder报找不到 Bean 时排查方向就很明确了要么是 starter 依赖没引对要么是配置文件里的前缀写错了要么是自动配置被某个条件排除了。我遇到过一次是因为配置文件里把spring.ai写成了spring.aii一个字母之差自动配置直接不生效找了半天。3.3 写一个最小可用的对话 Controller下面是一个能直接跑起来的最小示例。先注入ChatClient.Builder在构造方法里 build 出ChatClientRestController RequestMapping(/ai) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } GetMapping(/chat) public String chat(RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } }这段代码的逻辑很直白prompt()开启一次请求构建user(message)把用户输入作为 user 角色的消息放进去call()发起同步调用content()提取返回的文本内容。启动项目后访问http://localhost:8080/ai/chat?message你好就能看到模型返回的回复。3.4 加上 system 角色让回复更可控上面那个例子能跑但回复风格完全由模型自己决定。实际项目里通常需要给模型设定一个角色或者行为约束这时候就用system()方法GetMapping(/chat) public String chat(RequestParam String message) { return chatClient.prompt() .system(你是一个专业的 Java 技术顾问回答要简洁准确必要时给出代码示例。) .user(message) .call() .content(); }system 消息的作用是给模型设定全局的行为基调它比 user 消息的优先级更高。我实测下来加了 system 约束之后模型跑偏的概率明显降低尤其是让它“只回答 Java 相关问题”这类边界约束时效果很明显。但要注意system 消息不是万能的模型仍然可能在某些边界情况下不遵守所以关键业务逻辑不能只依赖 prompt 约束该做的校验还是要做。4. 流式输出与结构化返回的实战处理4.1 流式输出为什么体验差别这么大同步调用的问题是用户发完消息后要一直等直到模型把整段回复生成完才一次性返回。如果回复比较长等待时间可能好几秒用户会以为页面卡死了。流式输出则是模型生成一个字就推一个字用户能实时看到内容逐渐出现体验上完全是两个档次。Spring AI 里开启流式输出只需要把call()换成stream()GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }返回类型是FluxString配合produces TEXT_EVENT_STREAM_VALUE就是标准的 SSE 响应。前端用EventSource接收即可。这里有个坑要提醒stream()返回的 Flux 如果直接返回给 Spring MVC非 WebFlux环境可能不会按预期工作。Spring AI 的流式能力底层依赖 Reactor如果你的项目是传统的 Spring MVC需要确认依赖里有没有引入 Reactor 相关的包否则流式接口可能报类型转换错误。稳妥的做法是流式接口单独用 WebFlux 的依赖支持或者确认 Spring AI 的 starter 已经带上了必要的响应式依赖。4.2 结构化返回让模型输出直接变成对象对话场景返回纯文本没问题但很多时候我们需要模型返回结构化的数据比如从一段描述里抽取姓名、电话、地址。传统做法是让模型返回 JSON 字符串然后自己用 Jackson 解析但模型返回的 JSON 经常带 markdown 代码块标记或者字段名对不上解析起来很烦。Spring AI 提供了.entity()方法直接把模型返回映射成 Java 对象public record PersonInfo(String name, String phone, String address) {} GetMapping(/extract) public PersonInfo extract(RequestParam String text) { return chatClient.prompt() .user(从下面的文本中提取姓名、电话和地址 text) .call() .entity(PersonInfo.class); }.entity()内部会做两件事一是自动在 prompt 里追加格式约束告诉模型要按目标类的结构返回二是把返回内容反序列化成对象。实测下来对于字段不多的简单结构成功率很高。但字段一多、嵌套一深模型偶尔会漏字段或者类型对不上这时候需要在 prompt 里把字段说明写得更明确或者考虑用更严格的 schema 约束方式。4.3 多轮对话的上下文怎么维护上面所有例子都是单轮对话每次请求都是独立的。真实场景里用户往往需要连续对话模型要记得前面说过什么。Spring AI 提供了ChatMemory抽象来管理对话历史最常用的是基于内存的实现适合单机开发和小规模使用。配置方式是在构建 ChatClient 时挂上 memoryBean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultSystem(你是一个友好的技术助手) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }ChatMemory需要指定一个 conversationId 来区分不同用户的会话否则所有人的对话历史会混在一起。这个 conversationId 通常用用户 ID 或者会话 ID在调用时通过 advisor 的参数传入。我踩过的坑是一开始没设 conversationId测试时发现两个不同用户的对话内容串了排查后才发现是 memory 默认用了同一个 key。注意基于内存的 ChatMemory 在应用重启后会丢失而且多实例部署时各实例的内存不共享。生产环境如果要多轮对话需要换成基于 Redis 或者其他外部存储的实现否则用户换个实例访问就“失忆”了。5. 接入过程中那些文档不会写的坑5.1 内容审核拦截invalid prompt 的真实含义调用过程中你可能会遇到类似invalid prompt: your prompt was flagged as potentially violating our usage policy这样的报错。这个错误的意思是你的 prompt 内容被模型服务商的内容审核系统拦截了。注意这不一定是你真的写了违规内容有时候是措辞触发了审核规则比如某些敏感词组合、或者 prompt 里包含了看起来像攻击指令的内容。处理这类错误的关键是不要把它当成普通的网络异常去重试重试多少次结果都一样。正确做法是捕获这个异常给用户一个友好的提示同时记录下触发拦截的 prompt 内容用于后续分析。在代码里可以针对这类异常做专门的 catchtry { return chatClient.prompt().user(message).call().content(); } catch (Exception e) { if (e.getMessage() ! null e.getMessage().contains(flagged)) { return 抱歉您的内容暂时无法处理请调整后重试。; } throw e; }5.2 超时设置默认值往往不够用大模型生成回复的时间波动很大简单问题可能一两秒复杂问题或者长文本生成可能十几秒甚至更久。Spring AI 的默认超时时间在有些场景下偏短会导致请求还没返回就超时了。建议在配置里显式设置超时spring: ai: openai: chat: options: model: gpt-4o-mini超时的具体配置项在不同版本里位置不太一样有的在spring.ai.openai下有的需要自己配置底层的 HTTP 客户端。如果发现超时问题可以先从底层客户端的超时配置入手比如通过自定义RestClient.Builder或WebClient.Builder来设置连接超时和读取超时。我的经验是读取超时至少给到 60 秒流式场景下要更长因为流式连接是持续打开的。5.3 温度参数对结果稳定性的影响temperature这个参数控制模型输出的随机性。值越低接近 0输出越确定、越保守值越高接近 1 或更高输出越发散、越有创造性。做数据抽取、分类这种需要稳定结果的场景temperature 要设低0 到 0.3 之间比较合适。做创意文案、头脑风暴这类场景可以设到 0.7 以上。我一开始没注意这个参数用默认值做信息抽取发现同样的输入有时候能抽对有时候抽出来的字段格式就不对。后来把 temperature 调到 0.1稳定性明显提升。这个参数在application.yml里配置spring: ai: openai: chat: options: temperature: 0.15.4 依赖冲突Reactor 版本不一致的排查Spring AI 依赖 Reactor 做响应式处理如果你的项目里已经有其他依赖引入了不同版本的 Reactor可能出现NoSuchMethodError或者ClassNotFoundException。这类问题的典型表现是编译能过启动也能过但一调用流式接口就报错。排查方法是执行mvn dependency:tree看 Reactor 相关依赖reactor-core、reactor-netty有没有出现多个版本。如果有用dependencyManagement强制统一版本或者排除掉冲突的传递依赖。这个问题在同时用了其他响应式框架的项目里比较常见纯 Spring Boot Web 项目一般不会遇到。6. 从 Demo 到可用下一步该补什么6.1 Prompt 模板化别把提示词散落在代码里第一个 Demo 跑通之后你会发现 prompt 字符串散落在各个 Controller 里改起来很麻烦而且没法复用。Spring AI 提供了PromptTemplate来做模板化管理把提示词抽成带占位符的模板PromptTemplate template new PromptTemplate( 请将下面的文本翻译成{language}{text} ); Prompt prompt template.create(Map.of( language, 英文, text, 今天天气不错 ));这样做的好处是提示词集中管理改文案不用动业务代码而且占位符机制能避免字符串拼接带来的注入风险。对于提示词比较多的项目建议把模板统一放在资源文件里启动时加载进一步解耦。6.2 异常兜底与降级策略大模型服务不是 100% 可用的网络抖动、限流、服务端故障都可能发生。生产环境必须考虑降级调用失败时是返回缓存结果、返回固定话术还是走备用模型。我的做法是在 Service 层包一层把模型调用和业务逻辑隔开调用失败时根据业务重要性决定降级策略。对于非核心的辅助功能直接返回“服务繁忙请稍后重试”就够了对于核心功能要有备用方案。6.3 成本控制token 是要花钱的每次调用都会消耗 token输入和输出都算钱。多轮对话场景下历史消息会不断累积token 消耗增长很快。控制成本的手段有几个一是限制历史消息的保留轮数比如只保留最近 10 轮二是对输入文本做长度截断三是选择合适的模型简单任务用便宜的小模型复杂任务才用大模型。这些策略要在项目早期就考虑进去等账单出来了再优化就晚了。6.4 可观测性日志和指标不能少模型调用是黑盒出问题时如果没日志会很难排查。建议至少记录这些信息每次调用的请求耗时、消耗的 token 数、是否命中异常、使用的模型名称。Spring AI 本身有一些日志支持但业务层面的指标需要自己埋点。这些数据积累起来之后你能清楚地看到哪些 prompt 效果好、哪些场景成本高为后续优化提供依据。我在实际项目里还发现一个细节把每次调用的 prompt 和返回内容都完整记录下来对调试帮助极大。有时候模型返回的结果不符合预期回看当时的完整 prompt 才能发现是哪里表述有歧义。当然记录时要注意脱敏别把用户敏感信息原样落盘。最后分享一个我自己的习惯每接入一个新模型或者新版本的 Spring AI先写一个最简单的“你好”测试接口跑通确认链路没问题再去写复杂业务逻辑。这样能把环境问题和业务问题彻底分开排查效率高很多。AI 应用开发这件事工具在快速演进保持小步验证、快速迭代的节奏比一次性追求完美架构要务实得多。