ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring AI 实战:从零构建第一个 Java AI 应用

Spring AI 实战:从零构建第一个 Java AI 应用 Java 生态里做 AI 应用过去一年最大的变化就是 Spring AI 的出现。以前要在 Spring Boot 项目里接一个大模型能力得自己写 HTTP 客户端、拼 JSON、处理流式返回、管理对话上下文一套下来光是胶水代码就够喝一壶。Spring AI 把这些东西抽象成了和 JdbcTemplate、RestClient 一个级别的存在——你注入一个ChatClient调一下.prompt().user(...).call().content()模型就回你了。这篇内容面向的是有 Java 和 Spring Boot 基础、但还没真正跑通过一个 AI 应用的开发者我会从依赖引入、ChatClient 的构建、Prompt 的组织、流式输出、对话记忆到结构化输出把第一个能跑起来的 Java AI 应用完整走一遍中间踩过的坑和选型理由都会讲清楚。1. 先搞清楚 Spring AI 到底替你做了什么1.1 没有 Spring AI 之前接一个大模型有多麻烦我先还原一下裸接的场景这样你才能理解 Spring AI 的价值在哪。假设你要在 Spring Boot 里调用某家模型服务的对话接口标准流程是这样的引入一个 HTTP 客户端RestTemplate 或者 WebClient手动构造请求体——里面要放 model 名称、messages 数组、temperature、max_tokens 这些字段messages 数组里每条消息还要区分 role 是 system、user 还是 assistant。然后发请求、拿响应、解析 JSON、从 choices[0].message.content 里把文本抠出来。如果要做流式还得处理 SSEServer-Sent Events逐行读data:前缀的内容遇到[DONE]结束中间还要处理粘包和半包。这还只是一家服务商。哪天你想换个模型、或者同时接两家做对比上面这套代码基本要重写一遍因为每家请求体的字段名、鉴权方式、流式协议细节都不一样。更别说对话记忆——多轮对话需要你把历史消息拼回 messages 数组还要控制 token 长度别超限这些逻辑全得自己维护。我见过不少团队的第一版 AI 功能就是这么堆出来的一个AiService类里塞了三四百行HTTP 调用、JSON 解析、上下文管理、异常重试全混在一起后面想加个换个模型的需求改动面大得吓人。1.2 Spring AI 的抽象层次ChatClient 与 ChatModelSpring AI 的核心思路是把模型能力和模型实现分开。最底层是ChatModel接口它代表一个具体的模型实现比如对接某家服务的OpenAiChatModel、AzureOpenAiChatModel等等。ChatModel的call方法接收一个Prompt对象返回ChatResponse这一层是相对底层的、贴近模型原生语义的。再往上一层是ChatClient这是你日常写业务代码主要打交道的对象。它提供了 Fluent API 风格的链式调用把 Prompt 的组装、参数的设置、响应的提取都包装得很顺手。你可以把它理解成JdbcTemplate之于 JDBC 的关系——底层还是那套东西但用起来舒服太多。这个分层带来的直接好处是你的业务代码只依赖ChatClient具体底层接的是哪家模型通过配置和依赖来切换。今天用 A 家的模型明天想换成 B 家只要换掉对应的 starter 依赖和配置项业务代码一行不用动。这在做技术选型验证、或者需要按成本/效果切换模型的场景下价值非常大。1.3 版本与依赖起步阶段最容易卡住的地方Spring AI 目前的版本迭代比较快起步阶段最容易卡住的就是版本匹配。它和 Spring Boot 的版本是有对应关系的用错了组合会出现各种奇怪的类找不到或者方法签名不匹配的问题。我的建议是新项目直接用 Spring Boot 3.x 的较新版本然后引入 Spring AI 对应的 starter。以 Maven 为例核心依赖通常是这样组织的你需要一个模型实现的 starter比如对接 OpenAI 兼容接口的加上 Spring AI 的核心包。如果你用的是 Gradle把对应的坐标换成 Gradle 语法即可。这里有个细节Spring AI 的很多 starter 命名遵循spring-ai-{provider}-spring-boot-starter的规律引入 starter 之后自动配置会帮你把ChatModel和ChatClient.Builder都注册好你直接注入就能用。提示起步阶段不要一上来就手动 new 各种对象先让自动配置把 Bean 准备好跑通最小闭环之后再考虑自定义。很多人卡在第一步就是因为手动配置和自动配置打架了。2. 从零搭起第一个可运行的 AI 应用2.1 项目骨架与依赖引入的取舍我建议第一个应用就用最朴素的 Spring Boot Web 项目结构不要引入太多东西。核心依赖就三块spring-boot-starter-web提供 Web 能力方便你写个接口测试、Spring AI 的核心 starter、以及对应模型服务的 starter。如果你暂时不想申请真实的模型服务密钥很多模型服务都提供 OpenAI 兼容的接口你可以对接任意一个兼容端点配置里改base-url和api-key就行。这里有个选型上的经验第一个 Demo 不要追求接最先进的模型而是追求链路最短、最容易验证。因为你的目标是跑通 Spring AI 的调用链路而不是评测模型效果。等链路通了换模型只是改配置的事。依赖引入之后在application.yml里配置模型连接信息。典型的配置项包括spring.ai.openai.api-key、spring.ai.openai.base-url、spring.ai.openai.chat.options.model这几个。model这一项指定你要调用的具体模型名称temperature可以控制输出的随机性——数值越低越确定、越保守做事实性问答时通常调低一些做创意生成时调高一些。2.2 注入 ChatClient 并跑通第一次调用自动配置会给你一个ChatClient.Builder标准的做法是在配置类里用它构建一个ChatClientBeanConfiguration public class AiConfig { Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(你是一个严谨的 Java 技术助手回答尽量给出可运行的代码示例。) .build(); } }注意defaultSystem这个方法它设置的是系统提示词System Prompt相当于给模型定了一个人设和行为准则。这个设置会作用于这个ChatClient的所有调用除非你在单次调用时覆盖它。把通用的角色设定放在这里能省掉每次调用都重复写系统提示的麻烦。然后写一个最简单的 Controller 来验证RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } GetMapping(/chat) public String chat(RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } }启动项目访问/chat?message用一句话解释什么是依赖注入如果能看到模型返回的内容恭喜你第一个 Java AI 应用的链路就通了。.content()这个方法直接把响应里的文本内容取出来是最常用的快捷方法。2.3 第一次调用常见的失败原因排查第一次跑不通是常态我把最常见的几类问题列一下方便你对照排查。现象可能原因排查方向启动报 Bean 找不到starter 没引对或版本不匹配检查依赖坐标和 Spring Boot 版本对应关系调用返回 401api-key 配置错误或没读到检查配置项名称、环境变量是否生效调用返回 404base-url 或 model 名称不对确认端点路径和模型名拼写连接超时网络或端点地址问题确认 base-url 可达返回内容为空响应结构解析问题打开 debug 日志看原始响应其中配置没读到是最隐蔽的。Spring Boot 读取配置有优先级如果你同时在application.yml和环境变量里配了同一个 key环境变量通常优先级更高容易出现我明明改了 yml 怎么不生效的情况。排查时先把配置来源理清楚。注意调试阶段把 Spring AI 相关包的日志级别调到 DEBUG能看到请求体和响应体的原始内容定位问题效率会高很多。日志配置里加上对应包的logging.level设置即可。3. Prompt 的组织方式决定了输出质量3.1 系统提示、用户消息与占位符Prompt 不是一个字符串那么简单它是有结构的。一次对话请求里通常包含三类消息System 消息定义角色和规则User 消息是用户的实际输入Assistant 消息是模型之前的回复用于多轮对话。Spring AI 的ChatClient把这些都抽象成了对应的方法。除了直接传字符串Spring AI 支持模板化的 Prompt用{占位符}的形式定义可变部分。这在需要复用同一套提示结构、只替换其中几个变量的场景下特别有用。比如你有一个代码审查的提示模板里面固定了审查维度和输出格式只有待审查代码这一段是变的那就可以把模板固定下来每次只填代码。String answer chatClient.prompt() .user(u - u.text(请审查以下 Java 代码指出潜在问题\n{code}) .param(code, javaCode)) .call() .content();这种写法比字符串拼接清晰得多也避免了拼接时漏掉转义或者格式错乱的问题。3.2 提示词写得好不好差别有多大我做过一个对比同样让模型写一个 Java 方法判断字符串是否为回文一句干巴巴的指令和一段带约束的提示输出质量差距明显。带约束的提示会明确要求方法签名、是否考虑大小写和空格、是否要求时间复杂度、是否需要单元测试。约束越明确模型越不容易自由发挥输出越贴近你要的东西。这里有个反直觉的经验提示词不是越长越好而是约束越具体越好。堆一堆形容词请非常仔细认真地基本没用但明确说输出必须是可直接编译的 Java 17 代码不要包含解释文字就非常有效。模型对格式约束和角色约束的敏感度远高于对态度副词的敏感度。另一个实用技巧是给例子。如果你希望模型按某种固定格式输出与其用文字描述格式不如直接给一个输入输出的样例。这在做结构化提取、分类任务时尤其管用模型会模仿你给的样例格式。3.3 提示被拦截时怎么办实际使用中你可能会遇到提示被服务端拦截的情况返回信息里提示内容可能违反了使用规范。这类拦截通常发生在内容触发了服务商的安全策略时。遇到这种情况先别急着怀疑代码问题多半出在提示内容本身。处理思路有这么几条一是检查提示里是否包含了会被误判的敏感表述尝试换一种中性的表达方式二是把复杂的、可能引起歧义的指令拆解成更清晰、更聚焦的步骤三是如果确实需要处理特定领域内容确保提示的意图明确、上下文充分减少模型或安全策略的误判空间。从工程角度建议在代码里对这类异常做统一捕获和友好提示而不是把原始错误直接抛给前端用户。提示把提示词当成代码来管理。重要的提示模板应该纳入版本控制改动时记录原因方便回溯为什么这个提示要这么写。4. 让应用真正可用流式、记忆与结构化输出4.1 流式输出改善等待体验的关键大模型生成一段较长的内容可能要好几秒甚至十几秒如果等全部生成完再一次性返回用户盯着转圈会很难受。流式输出Streaming让内容边生成边返回首字延迟大幅降低体验提升非常明显。Spring AI 里开启流式很简单把.call()换成.stream()返回的是一个FluxString如果你用的是响应式栈或者对应的流式类型。在 Spring MVC 里你可以把它包装成 SSE 返回给前端GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }前端用EventSource接收即可。这里有个容易忽略的点流式接口的异常处理和普通接口不一样因为响应头可能已经发出去了中途出错没法再改 HTTP 状态码。所以流式场景下错误信息通常要以特殊的事件内容形式推给前端由前端识别处理。4.2 对话记忆多轮对话的上下文管理单次问答很简单但真正的应用几乎都需要多轮对话——用户问它有什么优点这个它指的是上一轮聊的东西。模型本身是无状态的每次调用你都得把历史消息带上它才知道上下文。Spring AI 提供了对话记忆的抽象核心是ChatMemory。你可以配置一个基于内存的实现它会自动帮你保存和拼接历史消息。使用时通过Advisor机制挂到ChatClient上Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }调用时通过参数传入会话 ID记忆组件就会按会话隔离地存取历史chatClient.prompt() .user(message) .advisors(a - a.param(ChatMemory.CONVERSATION_ID, sessionId)) .call() .content();这里有个必须注意的坑历史消息会不断累积最终撑爆模型的上下文窗口。所以生产环境一定要配窗口大小限制只保留最近 N 条消息或者做摘要压缩。基于内存的记忆实现重启就丢只适合开发调试生产环境要换成基于 Redis 或数据库的实现否则多实例部署时会话会串。4.3 结构化输出把模型回复变成 Java 对象让模型返回一段自然语言程序还得去解析这很脆弱。更好的做法是让模型直接输出结构化数据然后映射成 Java 对象。Spring AI 支持通过.entity()方法把响应直接转成你指定的类型record BookInfo(String title, String author, int year) {} BookInfo info chatClient.prompt() .user(请以 JSON 格式给出《Effective Java》的作者和出版年份) .call() .entity(BookInfo.class);底层它会引导模型输出符合目标结构的 JSON再反序列化成对象。用这个能力时提示里最好明确说明期望的字段和格式模型遵循度会更高。另外要防御性编程——模型偶尔会输出不符合结构的内容反序列化可能失败所以要有兜底逻辑比如捕获异常后重试一次或者降级处理。5. 从 Demo 走向可用几个绕不开的工程问题5.1 超时、重试与降级模型调用是典型的外部依赖网络抖动、服务端限流、偶发超时都会发生。生产代码里必须设置合理的超时时间并配置重试策略。但重试要谨慎对于生成类请求盲目重试可能造成重复计费和重复输出最好只对明确的连接类错误重试且限制重试次数。降级方案也要提前想好。当模型服务不可用时是返回一个友好的提示还是走一个规则兜底逻辑这个决策要在设计阶段就定下来而不是等线上出问题再临时加。5.2 成本与调用量的控制模型调用是按量计费的一个没控制好的循环或者被刷的接口账单会很可观。几个实用的控制手段对接口做限流按用户或 IP 维度限制调用频率对提示和响应的长度做上限控制对高频且结果稳定的查询做缓存。缓存这块要注意同样的输入在不同 temperature 下结果可能不同做缓存时要把影响结果的参数一起纳入缓存键。5.3 可观测性日志里该记什么AI 应用的调试比传统应用难因为输出是不确定的。日志里建议记录请求的会话 ID、提示的模板标识不是完整提示内容避免敏感信息落盘、调用的模型、耗时、token 消耗、是否命中缓存。这些信息在排查为什么这次回答不对和成本为什么涨了时非常关键。把提示模板和实际参数分开记录既能复现问题又能控制日志体积。我在实际项目里踩过最深的一个坑是早期没做会话隔离测试时几个人的对话历史混在一起模型回答得驴唇不对马嘴排查了半天才定位到是记忆组件共用了同一个会话 ID。从那以后会话 ID 的生成和传递我都在框架层面强制约束绝不让业务代码随手传。另外一个小经验第一个版本别急着上复杂的 RAG 或者 Agent先把单轮调用 流式 记忆这三件事做扎实大部分业务场景其实就够用了后面再按需扩展。
返回列表