尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring AI 实战:从 ChatClient 到多轮对话与工程化落地
第一次在 Spring 项目里接大模型我其实是有点抗拒的。倒不是说抗拒 AI 本身而是抗拒调一次接口要写的那一堆样板代码不同模型服务商给的 SDK 风格不一样参数命名不一样返回结构也不一样。今天接这家明天换那家改起来是真的头疼。所以当我第一次看到 Spring AI 把模型调用封装成类似RestClient的ChatClient时第一反应是这项目早该出现了。这篇内容就是一份带踩坑体质的 Spring AI 入门记录主要面向有过 Spring Boot 基础、想把大模型能力集成进现有应用的开发者。我会从「没有 Spring AI 时我们是怎么折腾的」讲起再到依赖配置、核心概念、多轮对话最后补上几个从 Demo 走到真实项目时绕不开的工程化细节。看完你至少能跑通一个最小示例并且知道自己接下来该往哪个方向深入。1. 没有 Spring AI 之前Spring 项目接入大模型有多别扭1.1 各家模型服务 SDK 的接入差异先泼一盆冷水如果你只想图省事直接调底层 HTTP 接口也能接大模型但这事远没有想象中那么简单。各家模型服务的接口风格虽然都朝 OpenAI 兼容格式靠拢但细节上的差异依然不少。比如有的用Authorization: Bearer有的要额外签名有的返回choices[0].message.content有的偏要在顶层包一层data更不要说流式输出时的事件格式简直五花八门。我之前的模拟项目 X 里就干过这种蠢事——为了支持两家模型服务我同时引入了两套 SDK还给接口写了一个抽象层。结果呢每家的 SDK 版本一升级我的适配代码就得跟着改有一段时间我几乎是在维护两个随时可能变形的「翻译器」。这种方案的坏处不是「代码多」而是「心智负担重」你写业务逻辑的时候脑子还要时刻记着哪家返回结构长什么样。1.2 Spring AI 想做什么模型接入的统一抽象Spring AI 的核心思路其实很朴素借鉴当年 Spring 对 JDBC 的那套处理方式把「不同数据库的差异」挡住给上层提供统一接口。放到大模型场景里它挡住的则是「不同模型服务商的差异」。换句话说Spring AI 本身不训练模型、不托管模型它的定位是应用与大模型之间的适配层。你写业务代码时面对的是ChatClient、ChatModel、Prompt这些稳定概念至于底层到底连的是哪家服务、走的是 HTTP 还是什么协议由自动配置帮你解决。切换模型服务商时通常只要改配置和引入对应的 starter业务代码可以保持不动。这里要强调一点Spring AI 不是 Spring 官方对某个模型的封装而是一整套面向 AI 应用的开发框架。它除了最基础的聊天对话还覆盖了结构化输出、工具调用、RAG检索增强生成、多模态、向量数据库接入等方向。入门阶段先抓住聊天这个主线就够了其他能力都是在这个主线上长出来的。1.3 Spring AI 不做什么边界很重要我见过不少人理解偏了以为 spring-ai-starter 只是帮你「调用大模型 API」的 HTTP 客户端。这只是它最基础的一部分。它真正值钱的地方在于把一次模型交互涉及的各种概念——消息、提示词、输出解析、上下文记忆、工具调用——都沉淀成了可组合的 API。你不需要自己维护一套「请求大模型」的基础设施。但它也有限制。比如它不会帮你选择模型不会替你优化提示词更不会让你的业务数据自动变得「适合大模型」。早期很多人抱怨 Spring AI 版本更新快、API 不稳定这不是框架本身的缺陷而是这个领域本身还在快速演进。你可以理解成大模型技术本身还在「高速修路」Spring AI 相当于在这条路上铺了一层相对好走的车道但路况天天变车道偶尔也要调整。2. 跑通第一个 Demo依赖、配置和最小代码量2.1 先决条件与依赖引入跑 Spring AI 需要的基础环境不复杂JDK 17 或更高版本Spring Boot 3.3 或更高版本Maven 或 Gradle 都可以。我日常习惯用 Maven例子就按 Maven 写。Spring AI 的依赖坐标很有特色它把不同模型服务商拆成了各自的 starter。你想接哪家就引入哪家不需要把所有的都装上。这里以 OpenAI 风格的接口为例先把 Spring AI 的 BOM 加到dependencyManagement里dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement然后引入 OpenAI 对应的 starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency这里有个小提醒Spring AI 的版本迭代相当快M 系列和 RC 版本的 API 都有过调整。我建议直接使用官方最新的稳定版本并且以你实际引入版本对应的文档为准不要照抄网上几个月前的老教程。2.2 最小示例一个 Controller 跑通聊天依赖引入后先配置模型服务的访问密钥。安全起见不要把密钥直接写在yml里我用的是环境变量注入spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL:https://api.openai.com}base-url这个配置很实用很多兼容 OpenAI 协议的模型服务和本地部署的推理服务都靠它接入。随后创建一个配置类把ChatClient注册成 BeanConfiguration public class AiConfig { Bean ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel).build(); } }最简调用直接在一个 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你好如果一切正常几秒钟后你会拿到模型的文本回复。整个代码量就这么点但这已经是一个完整的最小闭环了HTTP 请求进来ChatClient构造提示词底层ChatModel完成模型调用最终把回复内容作为接口返回值写出去。2.3 第一次请求的耗时与排查第一次跑通的时候我建议你注意一下接口耗时。大模型请求不像普通数据库查询响应时间通常在 1 到 10 秒之间甚至更长。我第一次跑的时候觉得「是不是卡住了」后来才发现是正常现象。如果等了十几秒还没响应优先排查三件事API Key 是否正确服务商是否对该网络环境开放。base-url是否配到了正确的服务端点和路径前缀。请求参数是否过大、消息长度是否超出模型上下文窗口限制。排查方式是打开 Spring Boot 的调试日志看底层 HTTP 请求是否发出、返回了什么状态码。之前就有朋友遇到 401 一直调不通最后发现代码里少了Bearer前缀这类问题往往比框架本身更容易坑人。3. 别只盯着聊天接口这几个核心概念决定你能否写出工程化代码3.1 三层对象各管什么ChatModel、ChatClient、Prompt入门阶段最容易搞混的是ChatModel、ChatClient和Prompt这三个东西。我用一句话帮你理清ChatModel是模型客户端的底层抽象负责与模型服务通信可以理解成 JDBC 里的Connection。ChatClient是给业务开发用的门面负责组装参数、调用模型、解析结果类似RestClient。Prompt是请求消息的载体你可以把它看成一次完整对话请求的「信封」。实际开发中你几乎不会直接操作ChatModel而是通过ChatClient来完成一切。这也是 Spring AI 设计上比较舒服的地方它把最容易变化的底层细节藏起来业务侧只面向一个统一入口。3.2 SystemMessage 与多消息结构很多初学者以为「提示词」就是一句user(你好)其实一次大模型请求可能包含多条消息并且这些消息的角色不同。最常见的三种角色是SystemMessage系统指令定义模型扮演的角色、回答风格和约束规则。UserMessage用户输入。AssistantMessage模型的历史回复。比如你想让模型扮演一个客服助手可以这样写String content chatClient.prompt() .system(你是一名电商客服回答要简洁礼貌不要编造订单信息。) .user(我的订单什么时候发货) .call() .content();ChatClient允许你在一次prompt()中同时塞入系统消息和用户消息后台会自动组装成模型需要的新消息结构。理解这个结构很重要因为很多复杂的场景——比如角色扮演、翻译润色、结构化抽取——本质都是在调整系统消息和用户消息的写法。3.3 流式响应从 String 到 Flux聊天类应用最影响体验的是等待时间。如果你每次都等模型生成完整回复再一次性返回用户面对的就是一个长时间转圈的页面。Spring AI 对流式输出支持得很好返回类型直接就是一个FluxStringGetMapping(value /chat/stream, produces text/event-stream) public FluxString chatStream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }前端通过 SSEServer-Sent Events就能一段一段接收模型生成的内容。实测下来流式输出对用户体验的提升非常明显首字延迟往往只有几百毫秒。我之前对接过一个内部工具一开始用的是非流式接口产品同事天天吐槽「转圈太久」换成流式之后吐槽声立刻消失了。3.4 结构化输出把大模型结果直接转成对象如果说 ChatClient 的文本回复只是「能用」那结构化输出才是「好用」的分水岭。想象一个场景你想让模型生成一份食谱不要一段自然语言而是一个包含标题、食材列表、步骤列表的对象。手写解析逻辑很容易被模型输出格式带偏Spring AI 提供了直接映射对象的能力。先定义一个记录类型public record Recipe( String title, ListString ingredients, ListString steps) { }然后让人工智能直接返回对象Recipe recipe chatClient.prompt() .user(给我一份蛋炒饭的食谱包含食材和步骤) .call() .entity(Recipe.class);注意这里的entity(Recipe.class)是当前稳定版里的写法早期 M 系列版本的写法是call().entity(...)如果你的版本不同需要留意调整。背后的原理是框架自动生成一段JSON Schema要求模型按这个结构返回再把 JSON 反序列化成目标对象。实际开发中我会把「需要稳定字段」的场景尽量都改成结构化输出比如信息抽取、表单生成、数据分类这比依赖自然语言解析可靠得多。3.5 工具调用让模型学会查天气大模型本身没有实时数据能力如果你问它「北京现在多少度」它是答不准的。工具调用Function Calling就是让模型在回答之前先调用你提供的函数把结果带回来再生成答案。Spring AI 的做法是把任意一个普通方法注册成工具Bean Description(根据城市名查询当前天气) public FunctionWeatherRequest, WeatherResponse currentWeather() { return req - weatherService.getCurrentWeather(req.city()); }调用时直接把工具名传给ChatClientString content chatClient.prompt() .user(北京天气如何) .tools(currentWeather) .call() .content();模型不会真的知道北京天气但它会发现「自己缺信息」于是触发currentWeather这个工具拿到结果后再组织语言回复。这套机制把大模型从「纯文本生成器」升级成了「能操作外部系统的智能体」是进阶开发最值得花时间研究的内容之一。4. 多轮对话不是把历史消息拼接起来Spring AI 的上下文方案4.1 为什么不能靠手工拼接聊天记录大模型本身是没有记忆的它是无状态接口。你要让它记住上一轮说过的话必须把之前的消息一起传给模型。最原始的做法是在代码里维护一个消息列表ListMessage history new ArrayList(); history.add(new UserMessage(我叫小明)); history.add(new AssistantMessage(你好小明)); history.add(new UserMessage(我叫什么名字));看着挺合理但真用到业务里问题一大推。首先是会话隔离每个用户的消息不能混在一起。其次是长度控制消息越攒越多总有一天会超出模型上下文窗口。最后是注入风险直接拼接历史消息很容易让系统提示词被用户「劫持」。所以历史消息管理应该是框架能力而不是业务代码里随手操作的集合。4.2 MessageChatMemoryAdvisor 怎么用Spring AI 提供的方案是MessageChatMemoryAdvisor加上一个ChatMemory实现。先在构建ChatClient时加上默认 AdvisorBean ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory())) .build(); }然后每次请求时通过 advisor 参数指定当前会话 IDString content chatClient.prompt() .user(我叫小明) .advisors(a - a.param(chat_memory_conversation_id, session-001)) .call() .content();下一次再用session-001发起请求框架会自动把该会话的历史消息带上。会话 ID 建议直接用业务侧的标识比如用户ID或订单号。不过InMemoryChatMemory只是把消息存在应用内存里适合开发和演示。真实环境一般会换成 Redis 或数据库实现这样应用重启后对话记录还在多实例部署也不会串记忆。4.3 上下文长度和会话隔离的坑我自己在这块踩过两个坑。第一个是上下文长度。默认情况下MessageChatMemoryAdvisor会按消息数量做截断但这并不总是够用。如果你传入的超长文档又不想让历史消息挤占太多上下文就得自己调整截断策略或者把历史消息做摘要。简单场景下限制保留最近十轮消息就够了复杂场景下要配合向量检索把相关内容抽出来这就是 RAG 的雏形。第二个坑是会话 ID 的一致性。我在模拟项目 X 里曾经因为前端没带会话 ID导致每一轮请求都生成新 ID用户每句话都像在跟一个「失忆」的机器人对话。排查半天才发现是参数传递问题不是框架问题。所以你在封装接口时一定要把会话 ID 当成一个显式的入参宁可前端多传一次也不要依赖隐式逻辑。5. 从 Demo 到真实项目绕不开的五个工程化细节5.1 多模型切换配置驱动而不是改代码如果业务只在某个固定模型上跑上文提到的最简配置完全够用。但真实项目里常见的情况是主模型一个厂商备用模型另一个厂商或者业务模块 A 用大模型业务模块 B 用便宜的小模型。Spring AI 允许你同时引入多个 starter并通过spring.ai.model.chat指定默认的 Chat 模型spring: ai: model: chat: openai代码里如果要用特定模型可以按 Bean 名称注入RestController public class ChatController { private final ChatClient openAiChatClient; private final ChatClient ollamaChatClient; public ChatController( Qualifier(openAiChatClient) ChatClient openAiChatClient, Qualifier(ollamaChatClient) ChatClient ollamaChatClient) { this.openAiChatClient openAiChatClient; this.ollamaChatClient ollamaChatClient; } }这样改动的只是配置和 Bean 定义业务代码不需要知道底层模型是谁。实际接入时各家模型服务的价格差异很大把成本敏感的请求路由到便宜模型是很常见的降本手段。5.2 超时、Token 上限与成本控制大模型服务不是本地数据库网络抖动、服务商限流、超时都不可避免。我建议在配置里显式设置超时时间不要用默认值硬扛spring: ai: openai: chat: options: temperature: 0.2 max-tokens: 2048 client: connect-timeout: 5s read-timeout: 60stemperature控制随机性需要稳定输出的场景建议调低到 0.1-0.3max-tokens直接决定模型输出长度也直接关系成本。另一个容易忽略的点是多轮对话中的历史消息每次都全量发过去Token 消耗会快速增长。我实际测试过一个含五轮对话的场景历史消息占的 Token 往往比当前问题还多。如果对成本敏感应该主动清理无关历史或者用更长的上下文模型分担。5.3 异常分类与降级策略接入大模型之后最怕的不是代码 bug而是不定时出现的服务方异常。我把实际遇到的问题整理成了三类异常类型常见原因处理思路鉴权失败Key 错误、Key 过期直接告警不要让用户重试限流/超时并发过高、服务方压力大退避重试或降级到备用模型内容过滤输入输出命中安全策略提示用户调整表述不要重试我封装了一个带降级逻辑的调用方法代码大概长这样public String chatWithFallback(String userMessage) { try { return chatClient.prompt() .user(userMessage) .call() .content(); } catch (Exception e) { log.error(chat failed, e); return 服务暂时繁忙请稍后再试; } }这只是最基础的兜底。更成熟的方案是把异常按类型细分分别走不同的降级路径比如切换到备用模型、返回缓存结果、或者把请求放入重试队列。关键原则是不要把所有异常都混在一起处理否则线上出问题的时候你连是「模型不行」还是「配置不对」都分不清。5.4 用量与耗时观测接入大模型后观测是刚需。不只是看接口成不成功更要看每次请求的耗时和 Token 消耗。Spring AI 本身支持 Micrometer 观测但我在实际项目中还是习惯在业务层自己打点因为能直接关联业务上下文。ChatResponse里带有 Token 使用信息ChatResponse response chatClient.prompt() .user(message) .call(); TokenUsage usage response.getMetadata().getUsage(); log.info(prompt tokens{}, completion tokens{}, total tokens{}, usage.getPromptTokens(), usage.getCompletionTokens(), usage.getTotalTokens());有了这些数据你才能回答三个核心问题这个功能每天花多少钱哪个接口调用最慢哪个用户的平均 Token 消耗异常高我的经验是Token 统计最好按业务线、按用户维度落库月底对账和优化时能省下大量猜来猜去的时间。5.5 从 Controller 里把 Prompt 抽出来最后一条也是我认为最重要的一条不要让 Controller 直接拼 Prompt。一旦 Prompt 逻辑和 HTTP 接口耦合在一起后面想调整提示词、切换模型、做日志统计都会很痛苦。我会把 AI 相关逻辑抽成一个独立的Assistant服务类Controller 只负责接收参数和返回结果。推荐的分层方式是这样的Controller 层只做参数校验、会话 ID 解析、统一响应封装。Assistant 层负责组装 Prompt、调用 ChatClient、解析结构化结果。Repository 层负责把需要的数据查询出来再喂给模型避免模型「凭空编造」。这个分层看似多写了一层代码但它带来的收益是巨大的。你需要调整提示词时Assistant 层改一行接口层完全不用动你要在调用前后埋点时多了一层天然的 AOP 切入点。等你的项目从 Demo 走向生产你会感谢当初没有偷懒省掉这一层。老实说Spring AI 现在还称不上完美版本迭代也确实快尤其是从 M 系列到稳定版之间API 的调整幅度不小。但它把 Spring 开发者接入大模型的门槛拉低了一大截这点是实打实的。我的建议是不用等它完全稳定再上手先用ChatClient跑通一个最小闭环再把结构化输出和工具调用用起来你会在几周内形成自己的套路。如果后面也踩到一些奇怪的坑欢迎回来交流我踩过的应该比你还多。
RELATED

相关推荐

从Q-learning到DQN:网格寻宝中的强化学习实战

从Q-learning到DQN:网格寻宝中的强化学习实战

如果你在小区里见过那种“找不到路就原地打转,被同一把椅子卡住三次”的扫地机器人,大概就能秒懂强化学习要解决的核心问题——不是“知道路怎么走”,而是“在没有地图、没有向导的情况下,靠自己试错走出一条路来”。我最近把“强…

📅 2026/10/18 4:32:12
AI芯片编译器后端:pto-isa寄存器分配与内存访问模型实践解析

AI芯片编译器后端:pto-isa寄存器分配与内存访问模型实践解析

做算子移植的时候,我在CANN的 pto-isa 工具链里调了一个非常折腾的寄存器分配问题。那段时间几乎天天对着llc -debug-onlyregalloc的输出分析虚拟寄存器的干涉图,折腾两周之后发现,问题的根子不在寄存器分配算法本身,而是出在我对…

📅 2026/10/18 4:32:12
gRPC自定义健康检查:从进程存活到业务就绪的工程实践

gRPC自定义健康检查:从进程存活到业务就绪的工程实践

1. 项目概述:为什么健康检查不是“配个探针就完事”的小事 gRPC 自定义健康检查,这六个字背后藏着的是微服务架构落地过程中最常被轻视、却最容易在凌晨三点把你从床上拽起来的硬骨头。我见过太多团队,在服务刚上线时用默认的 health.proto…

📅 2026/10/18 4:32:12
MORE NEWS

更多资讯

📰

mlx_lm 三行代码加载 Qwen3.6-35B-A3B-OptiQ-4bit-REAP-19B:本地推理快速上手指南

【免费下载链接】Qwen3.6-35B-A3B-OptiQ-4bit-REAP-19B 项目地址: https://ai.gitcode.com/hf_mirrors/mlx-community/Qwen3.6-35B-A3B-OptiQ-4bit-REAP-19B 点击查看 免费下载 Qwen3.6-35B-A3B-OptiQ-4bit-REAP-19B 是一款面向 Apple Silicon 的 MLX 量化 MoE 大模…

📰

ClickHouse .NET 驱动程序新特性:从 1.0 到 1.3 的演进之路

本文字数:12722;估计阅读时间:32分钟作者:Alex Soffronow Pagonidis编者按: 本文译自 ClickHouse 原博客(链接点文末-查看原文)。 原文围绕「相关技术主题」展开。 文章梳理了驱动在插入、读取和…

📰

TF-IDF+Logistic回归:情感分类Baseline的完整实战解析

很多刚入坑NLP的人都有这种感觉:学了一堆深度模型、BERT、注意力机制,可真到要做情感分类,反而不知道该从哪里下手。今年我陪一个项目组做了个小任务,给短评数据做情感二分类,第一版直接用 TF-IDF Logistic Regressio…

📰

YOLO实战:混凝土裂缝检测从数据集构建到模型部署全流程解析

1. 混凝土裂缝检测到底是什么问题1.1 传统检测方式的痛点在哪里先说点实际的。搞土木结构检测的朋友应该深有体会,桥梁、隧道、水坝外墙这些混凝土结构的定期巡检,最耗时的一环就是裂缝排查。传统做法是检测人员带着裂缝测宽仪、相机甚至望远镜&#xff…

📰

ABAP Cloud本地化日期输出:String Template与CL_ABAP_DATFM组合实践

做 ABAP Cloud 开发的人应该都有体会:日期输出这种东西,老代码里一句WRITE再加个格式选项就完事了,但在云环境里,很多老路子直接被堵死,越是基础功能越要重新设计。这篇文章想聊的就是一个很小但很磨人的点——怎么用 …

📰

Java自学避坑指南:五大常见误区与正确学习路线

网上关于Java学习路线的帖子多到刷不完,但我今天不想再给你列一张“必须掌握的知识清单”,而是想认真聊聊更值得关注的一类问题:Java学习误区。这些坑几乎每一个自学Java的人都会遇到,区别只是有人在里面待了大半年才爬出来&#…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬