ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI接入大模型:从yml配置到ChatClient四步调用实战

Spring AI接入大模型:从yml配置到ChatClient四步调用实战 Spring AI 出来以后我一直想找个最轻量的方式试一下毕竟后端项目里已经全是 Spring 那一套了如果接入大模型还要写一堆 HTTP 轮子那体验确实说不过去。直到我配好 yml、把 ChatClient 直接注入到项目里发现整个调用过程干净得有点舒服——构建客户端、组装 Prompt、发起调用、取 content四步链式就完成了一次对话。这篇就聊下我跑通全流程的真实经历从依赖版本、yml 配置到 ChatClient 的四个关键步骤最后把踩过的坑一并列出来。1. 为什么我从 Spring AI 接入大模型而不是手写 HttpClient1.1 传统对接方式的痛点我最早做 AI 功能集成的时候流程基本是固定的先去服务商的控制台申请 API Key然后看官方 SDK 文档把网络层、重试机制、超时设置、流式解析一个个都搭起来。这些工作重复性极高而且不同服务商之间的接口风格差异很大今天接 A 家写了套工具类明天接 B 家又得写另一套解析逻辑维护成本直接翻倍。更头疼的是鉴权和限流。有的接口返回 401有的返回 403还有的返回一个很隐晦的业务错误码每次遇到都要翻开文档逐字核对。再加上大模型接口本身不稳定偶发超时、连接重置、频率限制都太常见了如果代码里没有做好重试和熔断线上用户只要一碰到瞬时高峰体验就会非常差。还有一点容易忽略的是响应格式的差异。有的接口返回 JSON 嵌套有的走 SSE 流式输出还有的既能一次返回完整结果、也支持流式增量返回。早期我写过一个对接程序针对不同服务商写了三种解析器后来接口升级字段名变了又得全部改一遍。这种体验让我对“大模型接入很繁琐”这个判断特别认同也让我一直在等一个更统的抽象层出现。1.2 Spring AI 的设计思路恰好补上了短板Spring AI 给我的第一感觉就是它想把“对接大模型”这件事变成 Spring 生态里的一项常规配置。你在 yml 里把 API Key、Base URL、模型名、温度参数填好剩下的客户端构建、请求封装、结果解析全部由框架替你完成。对业务代码来说你只需要面对一个叫 ChatClient 的对象像操作一个普通的 REST 客户端一样去调用它。这种封装带来的好处不是少写几行代码那么简单。它把“响应式编程”“结构化输出”“提示词模板”“记忆上下文”这些在大模型应用中高频出现的能力全部做成了标准接口。你不需要自己实现流式接收也不需要手工拼接历史消息只要用链式方法把意图表达清楚框架就能帮你组织好请求。再一个让我比较看重的点是Spring AI 的抽象层是可替换的。你今天用 A 家的模型明天想换成 B 家的业务代码里可能只需要动 yml 配置和依赖坐标Controller 层和 Service 层基本不用改。这对需要做多云模型备份或者成本切换的项目来说吸引力非常大。所以当我在标题里写下“配好 yml 就能聊”的时候想表达的就是这个Spring AI 真正把大模型接入的门槛降低到了一个配置文件的量级而 ChatClient 四步链式调用正是这套设计里最典型的用法。2. 依赖准备与工程骨架搭建2.1 版本搭配别盲目追新我平时喜欢用新版本但在 Spring AI 上我吃过一次亏——直接用了刚发布的新版结果和 Spring Boot 的版本不兼容启动报一堆 Bean 找不到的错。后来养成的习惯是Spring AI 的版本和 Spring Boot 版本必须成对看最好用官方 BOM 统一管理版本避免自己手动指定相互不匹配的坐标。我这次演示用的是 JDK 17 Spring Boot 3.3.x 的组合。JDK 17 是当前后端项目里比较安全的选择LTS 版本Spring Boot 3.x 也强制要求 JDK 17 起步。如果你还在用 JDK 8 或者 Spring Boot 2.x想直接用 Spring AI 会比较吃力建议先把基础版本升上来。Spring AI 本身的版本号建议看它的官方文档因为迭代速度真的快我在做这个项目时用的版本是 BOM 引入的某个里程碑版本不过这不重要重要的是你引入的所有 spring-ai 相关依赖都要统一走同一个 BOM避免出现依赖冲突。2.2 引入模型 starter 的正确姿势Spring AI 把不同模型厂商的实现封装成了独立的 starter你只需要根据自己的服务商引入对应的依赖。我这儿没法把每个厂商的坐标都列出来因为服务商太多直接说思路更实用先找到你用的模型服务对应的官方文档里面会写清楚 starter 的 GroupId 和 ArtifactId然后引入进 pom 就行。需要注意的一点是不要只引入一个 starter 就完事。如果你的项目里要接多个模型服务得确保这些 starter 的版本都来自同一个 Spring AI BOM不然很可能会出现类冲突或者方法签名不一致的问题。我自己的做法是在dependencyManagement里先导入 Spring AI BOM之后各个 starter 只写 ArtifactId不写版本号让 Maven 自己解析。另外Spring AI 还依赖 Spring Boot 的自动配置机制所以你不需要手动地去 new RestTemplate 或者自己封装 HTTP Client框架会基于 yml 里的配置自动配置好底层的客户端 Bean。这部分不需要我们操心但前提是你引入了正确的 starter并且在 yml 里提供了它要求的配置项。3. yml 配置从零到能聊的关键三步3.1 最少必须要配置的三项第一次跑通 Spring AI 让我印象最深的就是这个原来真的只要三个必要配置就能发起对话。以我演示用的虚拟服务商 “provider” 为例实际使用请换成你自己的 starter 对应的前缀yml 里这样写spring: application: name: spring-ai-demo ai: provider: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: your-model-id先说 api-key。这里我非常建议用环境变量注入用${AI_API_KEY}这种占位符方式不要在 yml 里直接写明文。原因有两个一是 yml 文件会进入 Git 仓库一旦泄露就很难撤回二是不同环境之间的 Key 往往不一样写成环境变量可以方便在部署平台上动态注入。再说 base-url。这个是很多第一次用 Spring AI 的人容易忽略的地方。有些服务商的接口地址是默认的你可以不配但如果你用的是代理网关、内网部署的模型服务或者服务商提供了多区域的机房入口就必须把 base-url 指对。我见过有个朋友配错了区域地址结果每次请求都超时排查半天才发现是地址弄错了。最后是 model。这个就是你实际要使用的模型名必须和你的服务商那边开通的模型完全一致大小写都要对。如果你在调用时报错说模型不存在先别急拿配置里的 model 名去服务商的模型列表里核对一下一般问题都出在这里。3.2 温度、生成长度这些参数该不该动除了三项必填配置options里还支持很多可调项最常见的是 temperature 和 max-tokens。我的建议是温度参数可以大胆调但要知道它在干什么。temperature 的意思是生成的随机性值越低回答越确定、越保守值越高越有创造性和发散性。如果是做客服、问答、代码生成这种偏事实和逻辑的场景我会把温度调到 0.2 以下如果是写文案、头脑风暴、创意故事再考虑 0.8 左右。max-tokens 是限制生成内容的最大长度。这里有一个容易踩的坑它不是字数是 token 数而中文下 1 个 token 大概对应 0.6 到 1 个汉字不同服务商的分词器不一样精确关系没法一概而论。我以前写文档总结功能时设了 500 的 max-tokens结果输出经常只有三百多个字就被截断了后来直接改成 2048才够用。如果你需要长文本输出这个值尽量给足否则就算你 Prompt 写得再好回答也会被硬生生砍断。还有一点值得注意很多服务商有“上下文窗口”的概念max-tokens 设置得越大能留给系统提示词和历史消息的 token 空间就越小。所以如果你的系统提示词很长或者要做多轮对话就需要估算一下总 token 占用别把一次请求的 token 量顶到窗口上限。3.3 配置加载成功的验证方法yml 填完之后怎么确认配置真的生效了我通常会在启动日志里找线索。Spring Boot 的自动配置会在启动时打印很多信息Spring AI 相关 starter 如果成功加载你会看到类似“ChatClient bean created”或者“Auto-configuration enabled”的日志。有些服务商的 starter 还会打印出最终使用的模型名和 base-url 信息。如果你用的是 IDEA还可以直接看右侧的 Spring 面板里面会列出所有已注册的 Bean搜一下 ChatClient 在不在里面。如果在启动日志里看到了但这个 Bean 没有被注入到业务代码中那问题多半出在包扫描路径上——Controller 或 Service 所在的包不在主类的扫描范围里。这个错误非常隐蔽我一开始遇到的时候还以为自动配置没生效查了半天其实只是我忘了把启动类放最外层包。4. ChatClient 四步链式调用拆解4.1 第一步构建 ChatClientChatClient 的使用方式和我们常用的 Builder 模式很像。在 Spring AI 里你可以直接注入 ChatClient.Builder然后通过它来创建一个 ChatClient 实例。这个构建过程就是整个链式调用的第一步也决定了你后续对话的基础能力。Configuration public class AiConfig { Bean ChatClient chatClient(ChatClient.Builder builder) { return builder.build(); } }如果你项目里只需要一个 ChatClient直接用上面的写法注册成 Bean 是最方便的。但如果你要在不同的业务场景里用不同风格的对话就可以创建多个 ChatClient Bean每个 Bean 用不同的 default system prompt、不同的模型参数甚至挂不同的记忆策略。我在实际项目中就有一个需求面向内部员工的知识问答工具和面向终端用户的智能客服两边的语气和知识边界完全不同。我直接定义了两个 ChatClient Bean一个叫 supportChatClient一个叫 customerChatClient在构建时就分别写入了不同的默认系统提示词业务层拿到哪个就用哪个互不干扰。构建 ChatClient 时还有一个很重要的隐藏配置项默认的 System Prompt 可以在 Builder 阶段就设置好。这样你在后续调用时如果某个请求不需要覆盖它就可以少写一个 system 参数。这个设计对统一管理公司内部所有 AI 服务的使用规范很有效果。4.2 第二步组装 Prompt也就是 chatClient.prompt() 之后的链式填充拿到 ChatClient 之后最核心的调用就开始了。第二步是组装 Prompt逻辑上可以拆成三段启动 prompt 建立器、设置 system、设置 user。ChatClient 的调用链 chatClient.prompt() .system(你是一个有十年经验的 Java 后端工程师回答要简洁、准确、直接给出结论。) .user(请帮我分析这段代码的性能问题...这段代码...) .call() .content();你不需要手动去构造一个复杂的 Prompt 对象Spring AI 已经把聊天的所有要素抽象成了可链式调用的方法。system 方法设置角色定义和行为规则user 方法传入用户本次的问题或指令函数式编程的表达方式读起来几乎和自然语言一样。这里我最想提醒的是 system prompt 的重要性。很多人第一次写的时候随便填一句“你是AI助手”结果回答的质量非常平庸。后来我学到的经验是system prompt 应该包含角色身份、任务目标、输出格式、边界约束四个要素。比如你做客服场景就要写清楚“只能回答公司产品相关问题遇到不确定的内容要主动承认回答问题不要超过200字”。这比在业务代码里各种 if 判断要高效得多。user 部分也一样不是把用户输入原封不动传进去就完事了。你可以在这里拼接上下文信息、业务数据、历史对话等甚至可以结合模板方法动态地生成 user Prompt。Spring AI 也支持用 PromptTemplate 处理占位符模板这在批量生成或固定格式请求的场景下特别有用。如果你需要多轮对话prompt 之后还可以加 history 方法传入历史消息列表。这就是前面说的“记忆上下文”能力不用自己去组装一长串消息数组框架会帮你处理。4.3 第三步发起调用call 还是 stream 要看场景Prompt 组装好之后第三步就是发起调用。Spring AI 提供两个最常用的方法call() 和 stream()。call 是同步阻塞调用等到完整结果返回后才返回 response 对象stream 则是流式返回适用于打字机效果的场景。// 同步调用 ChatResponse response chatClient.prompt() .system(...) .user(...) .call(); // 流式调用 FluxChatResponse stream chatClient.prompt() .system(...) .user(...) .stream();同步调用写起来最简单适合内部工具、管理后台、批量处理这些不需要即时反馈的场景。它的缺点是如果模型响应时间较长请求线程会一直阻塞等待所以在线高并发场景下要小心线程池被打满。流式调用则适合用户直接面对的场景比如网页聊天窗口、命令行工具等用户可以马上看到第一个字体感上会快很多也能缓解长时间等待带来的焦虑。我个人试过把同一个请求改成 stream 后虽然总耗时没缩短多少但用户反馈“感受速度快了很多”。这就是流式的价值不在于真正的延迟降低而在于缩短感知响应时间。流式调用要格外注意异常处理。因为流式响应是分段返回的可能你在收到一半内容时底层的 HTTP 连接就断了。这时候 Flux 会抛出一个流式错误如果前端没做兜底提示用户会看到一条戛然而止的回答体验很差。所以我的做法是用 onErrorReturn 返回一个兜底的错误提示文案或者用 retry 做一次重试但重试之后要把已经展示的内容清掉不然会拼接出重复的文本。4.4 第四步从响应里取出内容content 不是唯一的选择调用完成之后拿到的是 ChatResponse 对象这个对象里包含的信息比单纯文本丰富得多。最常用的是 content()直接返回模型生成的文本内容。但如果你需要的是结构化结果比如让模型输出 JSON 格式的数据那 content() 拿到的字符串还得自己做一步解析。Spring AI 也提供了一些更高级的取结果方式。比如你可以使用实体映射功能让框架直接把模型输出映射成一个 Java 对象。这在做抽取类任务时非常好用比如让模型从一段文本中提取出客户姓名、订单号、金额等字段然后直接得到对应实体类。OrderInfo orderInfo chatClient.prompt() .system(从用户文本中提取订单信息输出格式严格按照页面给出的 JSON Schema。) .user(帮我查一下 11 月 5 号的订单 123456金额 1500 元) .call() .entity(OrderInfo.class);这种方式有一个前提就是你的 Prompt 里要明确要求模型按指定格式输出否则模型很可能输出一堆解释性文字导致映射失败。我踩过这个坑第一次用 entity 映射时没有在 system prompt 里强调“不要输出多余内容”结果是模型在 JSON 前后各加了一句“好的这是您要的信息”框架解析直接报错。所以如果你要使用结构化输出建议在 system prompt 里写清格式要求最好给出一个例子这样成功率会大幅提升。而且要注意不同模型的指令遵循能力有差异有的模型即使是明确要求了也可能偶尔输出格式不对所以代码里要预留解析失败的兜底逻辑。5. 一个能直接运行的聊天接口 Demo5.1 Controller 与 Service 分层四步链式调用看着简单落到实际项目里还是要分好层。我习惯是这样Controller 层只接收请求参数并把结果返回给前端Service 层组装 ChatClient 调用链配置数据放在 yml 里。这样做的意思是未来如果要从 A 模型切到 B 模型Controller 层完全不用动Service 层里也只是换一个配置类的事情。直接看代码RestController RequestMapping(/api/ai) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } GetMapping(/chat) public String chat(RequestParam String message) { return chatService.chat(message); } GetMapping(/stream) public FluxString stream(RequestParam String message) { return chatService.stream(message); } }Service 层再把 ChatClient 注入并使用Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient ChatClient) { this.chatClient chatClient; } public String chat(String message) { return chatClient.prompt() .system(你是一位耐心、专业的智能助手回答请分点列出单点控制在 50 字以内。) .user(message) .call() .content(); } public FluxString stream(String message) { return chatClient.prompt() .system(你是一位耐心、专业的智能助手回答请分点列出。) .user(message) .stream() .map(ChatResponse::getResult) .map(Generation::getOutputText); } }这套结构跑起来后前端只要 GET 一下接口就能拿到结果/stream接口如果你用 SSE 或者 WebFlux 的响应式前端来对接就能看到逐字输出的效果。我在搭建时还加了一个小功能在 Controller 的入参里限制 message 长度。因为大模型的接口是按 token 计费的如果用户传了一篇几万字的文章进来成本会很可观而且响应时间也成倍增长。所以我在接入层用Validated配合Size注解给 message 字段加了一个最大长度校验请求体超过阈值直接返回提示信息根本不会走到模型调用那一步。5.2 带上下文的多轮对话实现如果只做单轮问答上面的代码已经完全够用。但大多数实际项目都需要多轮对话模型需要知道用户刚才问过什么问题。这时候就需要把历史消息传进去让对话保持连贯性。多轮对话的思路很简单把之前轮流出现的 user 消息和 assistant 消息按顺序拼接成一个消息列表。Spring AI 的 ChatClient 也支持直接在链式调用里加 history 参数你不需要自己拼字符串。我在一个测试项目里做的记录功能是把每一轮对话都存到数据库下次请求时把最近的 20 条消息取出来组成 history传给 ChatClient。public String chat(String userId, String message) { ListMessage history messageService.getHistory(userId, 20); return chatClient.prompt() .system(你是智能助手请基于已有上下文回答问题。) .history(history) .user(message) .call() .content(); }history 不只是用来保存历史消息还能让你的应用具备“记忆”能力。比如用户上一轮说了一句“我身高 175 厘米”这一轮直接问“帮我推荐穿搭风格”模型就可以根据历史消息里的身高信息给出更精准的回答。这种能力在做私人助理、体检报告解读、个性化推荐等场景里价值非常明显。不过也要注意上下文长度限制尤其是这类多轮对话场景模型要一次性接收所有历史消息并生成回答如果历史消息太多会把上下文窗口撑爆而且成本也会波动很剧烈。我自己的习惯是只保留最近 20 到 30 条消息同时给每条消息设置一个字符上限再长就截断处理和摘要化存储从而平衡记忆能力和成本消耗。6. 常见问题与排查技巧6.1 401 鉴权失败我最常遇到的问题就是 401。一开始还以为是 yml 配置拼错了后来排查发现是环境变量没配好。IDEA 里的 Environment Variables 那个选项没填导致${AI_API_KEY}变成了空字符串。Spring AI 请求发过去服务商返回 401 Unauthorized。排查方法很简单在启动类里临时加一行打印看看System.out.println(environment.getProperty(spring.ai.provider.api-key));如果打印出来是 null说明环境变量没生效。这种问题不算大但遇到一次就会印象特别深。从这里得到的经验就是凡是外部配置项最好都走环境变量这样既能避开把密钥写死在源码里又方便在不同环境之间切换。6.2 请求超时与重试配置模型接口偶尔会慢到让人怀疑人生特别是某些复杂的模型在某些时刻负载大了以后请求可能要十几秒甚至几十秒才返回。HTTP 客户端的默认超时时间可能比较短如果服务商的接口还没处理完客户端已经主动断了连接那请求就失败了。Spring AI 的默认连接超时时间是可配置的你可以在 yml 里把底层的 connect timeout 和 read timeout 调大也可以直接用重试模板。我用的服务商偶尔会返回 429 限流和 503 过载我的策略是重试三次每次间隔 2 秒这个值不建议设太高否则在服务商限流时反而容易把限流阈值打得更满。这里的经验是重度依赖模型的接口必须设计兜底方案。即使重试之后仍然失败也要给前端返回一个“服务暂时不可用稍后再试”的友好提示而不是把异常栈直接抛出去。打印完整日志是核心需求。否则用户在大半夜只看到一条 500 报错什么信息都没有排查起来非常痛苦。6.3 响应内容被截断内容被截断出现的频率也挺高。表现就是模型回答到一半突然停了有时候甚至最后几个字是半句话。如果你遇到这个问题优先检查 max-tokens 参数是否偏小。很多模型默认的 max-tokens 可能只有几百一旦你的回答目标字数稍微多一点就会被硬截断。其次检查 Prompt 里是否要求了很长的输出格式。比如你让它输出十点建议、每点一百字那需要的 token 量就得上千。我在做周报自动生成功能时就遇到过设了 1024 tokens 结果还是被截断改成 2048 之后才正常。还有一种场景是流式调用时前端把消息拼接错了导致看起来像截断实际是接收逻辑有 bug这种情况要分清是生成侧截断还是接收侧遗漏。6.4 配置不生效ChatClient 注入失败有些时候 yml 看着没问题但程序启动时就是报找不到 ChatClient 类型的 Bean。这种基本都是自动配置没触发常见原因也简单你没引入对应的 starter或者引入了但类根本不在扫描路径里。处理这个毛病的排查顺序我建议是先看 pom 里有没有 spring-ai 相关的依赖再用mvn dependency:tree看依赖是否真的引入成功最后检查主启动类的位置。我把启动类放在根包之外过当时的报错信息其实是模糊不清的走了弯路才醒悟到直接把启动类上移动到最外层包除了整改下目录其他都不需要改。如果还是不行试着把 Spring Boot 的 debug 日志打开看自动配置报告里有没有包含 SpringAi 相关的配置条件结果是匹配还是失败。这个报告能直接告诉你缺了什么条件是查这类问题最有用的工具。提示遇到任何 ChatClient 相关的注入问题先看一眼自动配置报告它会列出每个自动配置类生效还是失效几乎能定位到一半以上的问题。最后分享几个小习惯每次用 Spring AI 做新项目我基本都会保留这么 4 个习惯。第一API Key 永远只从环境变量读取哪怕本地开发图省事写明了也要在提交到 Git 前改成占位符。第二所有调用链的 system prompt 单独放在一个常量类或者配置里统一管理方便以后调整文案也方便审计每个业务场景用了什么规则。第三每个业务场景都记录了一个简单的 token 消耗日志这样月底拿去算成本的时候不用靠猜。第四只要是流式调用前端一定会配套一个“停止生成”的按钮避免用户每次都要等模型把话说完。这套组合用法跑下来我感觉 Spring AI 带给我最大的价值不是省掉了十几行代码而是把大模型接入的复杂度封装到了一个可控的范围内让我能把更多精力放在业务逻辑、提示词设计和数据管理上。如果你还没试过按这套四步链式的思路去跑一个最小 Demo配好 yml 的那一瞬间你会发现原来接入大模型真的可以这么简单。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进