资讯动态

Spring AI核心组件实战:ChatClient与ChatMemory搞定上下文管理

发布时间:2026/10/22 14:02:26 来源:尧图企业网站定制
如果你是一个常年用Spring Boot写业务接口的Java开发者第一次把大模型能力接进项目时多半会有一段“浑身别扭”的时期。普通后端是“请求进来-处理-返回”一条直通路模型应用却是“提示词怎么组织、历史消息怎么维护、系统角色放在哪一层”的全新问题我早期甚至为了一段多轮对话手写了七八个工具类。Spring AI这套框架核心就是把这些散落的组件整理成一套符合Spring风格的东西模型是Bean提示词是模板记忆是拦截器大家通过依赖注入协作而不是每次调用都重新搭一次脚手架。这篇文章我不会去念官方文档而是直接讲Spring AI里几个核心组件到底解决了什么问题、互相之间怎么配合以及我实际接入时踩过的一些坑。内容以当前稳定版特性为主如果你用的版本API略有出入以IDE自动补全和官方迁移文档为准。适合已经会用Spring Boot、想在大模型应用里少走弯路的人。1. 先说我为什么需要Spring AI裸调模型接口的麻烦事1.1 你以为只是“发个HTTP请求”其实麻烦在后面最早我接大模型的方式很直接拿HttpClient发POST把用户消息拼成一个JSON数组等模型返回再解析。单次调用确实简单可一旦要做真实业务痛点马上冒出来。首先是格式不统一。不同模型服务商的请求体结构、鉴权方式、错误码都不一样今天调用A家明天想换B家几乎等于重写一层客户端。更麻烦的是模型返回的消息里经常带出格式差异有的带内容字段有的带数组结构解析逻辑跟着烂。其次是上下文管理。模型本身没有记忆所谓“多轮对话”本质是你每次调用都把之前的对话记录再发一遍。这就意味着你得自己维护一份历史消息列表还要考虑历史太长时怎么裁剪按条数裁还是按字符数裁系统提示词要不要裁。我用纯手写代码做这层逻辑时出错率极高要么全量塞进去导致超出上下文限制要么裁太狠把关键信息裁没了。再有就是横切逻辑无法复用。重试策略、超时控制、日志记录、提示词安全过滤这些逻辑放在调用代码里会越堆越乱但你又很难抽成通用方法因为每次调用的参数结构都不一样。1.2 Spring AI到底做对了什么Spring AI的解决思路很清晰先定义一套稳定的抽象模型再让各家模型服务商通过自动配置提供实现。你业务代码里面对的是抽象接口换模型只是换个配置的事。它把一次完整的模型调用拆成了几个可组合的环节消息数据结构、模型抽象、面向业务的门面ChatClient、还有处理横切逻辑的Advisor链。这样“发送提示词”和“管理上下文”“记录日志”这些行为被解耦了代码结构一下子就清晰了。我用一个类比来理解只调HTTP接口相当于自己写JDBC而Spring AI相当于把数据库访问抽象成了JdbcTemplate。你依然在操作SQL提示词但连接管理、异常处理、参数绑定这些麻烦事框架帮你收口了。1.3 什么场景适合用什么场景别硬上不是所有项目都适合引入Spring AI。我用过之后总结了一张判断表场景是否推荐原因已有Spring Boot项目需要接对话能力强烈推荐自动配置、Bean管理、与现有代码无缝集成需要同时兼容多家模型服务商推荐抽象层让切换成本降到最低只需要一次性调用不涉及上下文和业务扩展不推荐引入框架显得重直接调SDK更省事重度依赖某个模型特有参数和高级能力谨慎抽象层会抹平特性极端场景可能要绕过框架我见过一些团队不管三七二十一先引入再说结果发现项目只需要一个简单的接口转换最后白白增加依赖体积。框架是解决复杂度的不是制造复杂度的。2. 组件地图ChatModel、Prompt、ChatClient、Advisor各管哪一段2.1 ChatModel底层模型的统一翻译层ChatModel是Spring AI里最底层的模型抽象。它的职责很纯粹接收消息输入返回模型输出。你可以把它理解成JDBC里的Driver各家模型服务商通过实现这个接口把自己的差异封装在后面。实际使用中你很少直接操作ChatModel但要知道它是整个链路的起点。启动时Spring Boot的自动配置会根据你所选的依赖装配出一个ChatModel的Bean。这个Bean背后是“哪个模型服务商、什么API地址、什么模型名称”这些配置都写在application.yml里。2.2 Prompt与Message先有统一数据结构才能有统一对话在Spring AI里一次对话的内容不是裸字符串而是由Message接口构成的列表。Message下分了几种角色用户消息、系统消息、助手消息。系统消息用来设定角色和行为用户消息是真实输入助手消息是模型上一步的输出。所有历史对话本质上就是这些Message排成的一个队列。Prompt这个类把消息列表和模型参数temperature、maxTokens这类打包在一起。可以说Prompt才是真正传给模型的数据结构ChatModel接收的也是它。我刚开始学的时候总搞混ChatClient和ChatModel的分工后来记住一句话ChatModel只负责“给什么返回什么”而ChatClient负责“把业务输入加工成Prompt”。2.3 ChatClient面向业务开发者的主入口ChatClient才是你日常开发中打交道最多的组件。它把提示词构造、参数绑定、Advisor执行、结果转换这些流程串成一条链对外暴露的方法很直观。chatClient.prompt() .user(你好) .call() .content();这种链式写法和Spring的RestTemplate、JdbcTemplate风格类似Java开发者上手几乎没有障碍。ChatClient内部持有ChatModel和一个Advisor列表调用时先处理Advisor再交给ChatModel执行。如果你需要大量自定义能力也不要自己绕开ChatClient去拼底层接口它提供了足够的扩展点。2.4 Advisor横切逻辑的拦截器Advisor这个概念初看有点绕但它本质上和Spring MVC里的Interceptor是一回事。你可以在一次模型调用的前后插入额外的处理逻辑最常见的就是“从内存里取出历史消息、追加到当前Prompt里、调用完成后把本轮对话存回去”。框架内置了好几个现成的Advisor比如消息记忆Advisor、日志Advisor、系统提示词Advisor。你也可以实现接口自定义逻辑比如给提示词做敏感信息过滤。多个Advisor的执行顺序是有讲究的这个我后面结合踩坑经历细讲。2.5 组件之间的装配关系整理一张表帮你快速建立心智模型组件一句话职责类比我什么时候直接碰它ChatModel调用具体模型的抽象入口JDBC Driver很少一般通过自动配置生成Prompt封装消息列表和模型参数SQL语句不直接new由ChatClient构造Message对话中的单条消息实体参数对象处理历史消息时ChatClient面向业务的门面串联所有环节JdbcTemplate几乎每次使用Advisor横切逻辑拦截器MVC Interceptor注册记忆、日志、系统提示时组件之间的关系是一条单向链路业务代码 → ChatClient → Advisor链 → Prompt → ChatModel → 模型服务商。不要试图跳过某一层每一步都有它存在的意义。3. 从一段能跑的ChatClient代码开始同步、流式、参数绑定3.1 依赖与配置先让自动配置跑起来以Maven项目为例我通常会引入BOM统一管理版本再引入对应的模型服务商Starter。具体Starter名称取决于你接哪家服务商我这里用占位符示意dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model/artifactId /dependency /dependencies配置文件里把模型基础信息交给环境变量不要把密钥写死在文件里spring: ai: model: chat: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} model: ${AI_MODEL_NAME}这样一个可用的ChatModel Bean就已经在容器里了。剩下的工作都是注入和写业务代码。3.2 同步调用注入Builder并构建ChatClient在Spring AI里推荐的做法是注入ChatClient.Builder在Service初始化时构建出自己的ChatClient实例。这样你在构建时可以统一设置默认Advisor和系统提示词。Service public class ChatAssistantService { private final ChatClient chatClient; public ChatAssistantService(ChatClient.Builder builder) { this.chatClient builder.build(); } public String chat(String userMessage) { return this.chatClient.prompt() .user(userMessage) .call() .content(); } }这段代码虽然短但说明了一件事厂商的模型差异被完全屏蔽了。你在这里不关心底层API长什么样模型返回什么结构只要有ChatClient就能对话。3.3 系统提示词与参数绑定别再手动拼接字符串我发现很多初学者喜欢用String.format拼提示词这样做不仅可读性差还容易因为参数注入引发意外。Spring AI提供了类似日志模板的参数绑定机制比较稳妥。public String chatWithSystem(String userMessage) { return this.chatClient.prompt() .system(spec - spec .text(你是{domain}领域的智能助手回答时请使用{language}。) .param(domain, 订单售后) .param(language, 简体中文)) .user(userMessage) .call() .content(); }param方法会把模板里的占位符替换成真实值。这样系统提示词模板可以维护在配置中心或代码常量里业务参数动态传入改起来很灵活。3.4 流式输出从调用一个接口到Flux一朵流真实场景里用户更希望看到“字一个个蹦出来”的效果而不是等几十秒后一次性看到全文。这时要使用流式调用Spring AI基于响应式编程的Flux来实现。public FluxString streamChat(String userMessage) { return this.chatClient.prompt() .user(userMessage) .stream() .content(); }在Controller里直接返回这个FluxSpring WebFlux会自动按SSE的方式把内容推给前端RestController public class ChatController { private final ChatAssistantService chatAssistantService; public ChatController(ChatAssistantService chatAssistantService) { this.chatAssistantService chatAssistantService; } GetMapping(/chat/stream) public FluxString chatStream(RequestParam String message) { return chatAssistantService.streamChat(message); } }这里有个值得注意的设计流式返回的不是业务对象而是一串数据流。调用的错误处理不能再依赖抛出异常而是要在Flux的onError回调里做兜底或者让前端根据流的中断来提示失败。3.5 把参数校验加在调用之前接过大模型的人都清楚提示词越长花费越高响应越慢。所以我不建议把原始用户输入直接透传至少要做长度校验和基础清洗public void validateMessage(String userMessage) { if (userMessage null || userMessage.isBlank()) { throw new IllegalArgumentException(消息不能为空); } if (userMessage.length() 2000) { throw new IllegalArgumentException(消息过长请控制在2000字以内); } }这一步看起来简单但能拦住大量无意义请求也避免用户误把超长文本丢给模型造成不必要开销。4. 让模型记住上下文ChatMemory的正确用法和关键顺序4.1 模型没有记忆“记忆”是我们喂进去的历史消息大模型本质上是一个无状态函数你给它什么输入它就输出什么。所谓“上下文记忆”靠的是每次调用时把过去的对话记录重新塞进输入里。Spring AI把这块封装成了ChatMemory组件专门负责保存和读取历史消息。你可能会问“这不就是我自己用List存消息吗”区别在于手写方案要自己处理窗口截断、会话隔离、并发安全而ChatMemory组件把这些都规范化了尤其是与其他核心组件的协作。4.2 滑动窗口策略MessageWindowChatMemory是什么最常见的实现是MessageWindowChatMemory它只保留最近N条消息。比如设置5那么历史列表中只维护最近5条User和Assistant消息超过的部分自动丢弃。Bean public ChatMemory chatMemory() { return new MessageWindowChatMemory(10); }窗口大小怎么定不能拍脑袋。我通常用一个公式估算可用上下文长度 ≈ 模型最大上下文长度 - 系统提示词长度 - 预计输出长度如果模型支持8K上下文系统提示词占1K输出准备留2K那历史消息最多占5K。具体能放多少条消息取决于业务里单条消息的平均长度。窗口设置过大可能导致请求超出限制设置过小模型又记不住前面的内容。4.3 关键一步把ChatMemory通过Advisor挂到调用链上光定义ChatMemory的Bean还不够它要作为Advisor注册到ChatClient上才能生效。我一般这样配置Service public class MemoryChatService { private final ChatClient chatClient; public MemoryChatService( ChatClient.Builder builder, ChatMemory chatMemory) { this.chatClient builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } }这样每次调用ChatClient时Advisor会自动从内存里取出历史消息拼到当前Prompt前面并在调用结束后把本轮消息回写进内存。你的业务代码完全不用手动处理历史消息。4.4 多用户隔离会话ID是续命的钥匙如果所有人都用同一个ChatMemory那A用户说过的话会被B用户看到这显然不行。解决办法是通过Advisor参数传入会话IDpublic String chatWithMemory(String sessionId, String userMessage) { return this.chatClient.prompt() .user(userMessage) .advisors(a - a.param(chatMemory.conversationId, sessionId)) .call() .content(); }我习惯用业务维度的ID比如订单号、工单号或用户ID。这样每个会话天然隔离。如果你用的是普通Map缓存建议引入过期清理机制避免会话ID越来越多把内存吃光。4.5 别忽视一个危险点系统提示词也被算进历史消息这是很容易踩坑的地方。MessageChatMemoryAdvisor在保存历史时默认会把整个Prompt保存下来如果我每次调用都通过.system(spec - ...)动态设置系统提示词那每轮对话都会往历史里追加一整条系统提示词而且它们不会被去重。表现就是聊了几轮之后传给模型的历史消息里塞满了重复的系统提示词真正的用户对话反而被挤出了窗口模型开始“失忆”。这个问题我后面专门细讲你可以先记住一句话系统提示词的设置方式要和记忆策略在一起设计而不是各自孤立的。5. 一次现场排错我的聊天记录为什么轮数一多就“失忆”5.1 现象前两轮正常第三轮开始忘记人设我做顾问机器人Demo时配置了系统提示词“你是某产品的售后顾问回答简洁专业”。前两轮问答都正常第三轮开始模型突然忘记了自己的人设回答变得口语化还出现“好的有什么可以帮您”这种客服味浓重的话。当时我就意识到一定是什么东西把系统提示词挤掉了。5.2 第一轮排查方向窗口调大就能解决天真了我一开始以为是窗口太小把MessageWindowChatMemory从10调到30重新运行发现问题更严重了甚至第一轮就开始记不住。这时候我才反应过来问题不在窗口大小而是历史消息队列里塞了太多无用信息真正的对话内容根本没占到应有的位置。5.3 抓包看真实请求原来每条系统提示词都被记录下来了我用日志把发送给模型的完整请求体打印出来发现每次请求里历史消息区密密麻麻排着一堆SystemMessage每一条的内容都是一样的都是那句“你是某产品售后顾问”中间夹杂着几条User和Assistant消息。窗口越大重复累积的系统提示词越多有用的对话反而越靠后最终被彻底挤出上下文。根因一句话就能说清我在调用时通过.system(spec - ...)临时设置提示词消息记忆Advisor在回写历史时把这个动态系统消息也当成普通消息存了进去。每一轮都存一条轮数多了自然爆炸。5.4 修复把系统提示词从“动态消息”改成“固定默认值”我做了两个调整。第一在构建ChatClient时用defaultSystem统一设置系统提示词而不是每次调用都动态传this.chatClient ChatClient.builder(chatModel) .defaultSystem(你是某产品的售后顾问回答简洁专业) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();第二如果确实需要动态系统提示词在记忆回写阶段排除系统消息。具体做法取决于你的依赖版本可以查阅MessageChatMemoryAdvisor的源码看它是否提供系统消息过滤选项。我只在需要精确控制时用这个方案大多数情况defaultSystem就够了。5.5 同一个坑的变种会话之间互相“串话”修完记忆问题后又遇到一个更隐蔽的问题两个用户同时在测试用户A问“我的订单到哪了”用户B的对话里竟然出现了A的订单号明显是串了。原因是我只定义了一个全局ChatMemory Bean没有按会话隔离。解决办法就是我之前说的每次调用传入conversationId。排查时我先看有没有传会话ID发现忘传了补上之后串话消失。这个坑提醒我任何涉及状态管理的组件都要在第一个版本就考虑隔离问题而不是出了问题再补。5.6 一张排错清单帮你少走两小时弯路现象最可能的原因排查顺序对话轮数一多就忘记早期内容人设崩塌系统消息被重复累积挤出有效上下文1. 抓完整请求体 2. 检查历史消息里系统消息数量 3. 改用defaultSystem不同用户之间能看到彼此对话全局ChatMemory未按会话隔离1. 检查是否传了conversationId 2. 检查内存缓存是否有会话维度调整窗口大小后反而更差窗口内塞满重复/无用消息1. 看历史队列每条消息是什么 2. 排除系统消息后再看有效长度回答速度越来越慢历史消息总量过大1. 估算Token占用 2. 缩小窗口或改用更精简的系统提示词排查这类问题的核心方法论就一个别猜把发给模型的完整请求打印出来亲眼看历史消息里到底存了什么。6. 接入业务系统时最常见的三种接法6.1 接法一Service封装把模型调用变成业务方法最朴素也最通用的做法是在Service层封装ChatClient对外暴露的是业务语义明确的方法而不是“发一条消息”这种技术语义。Service public class OrderAssistantService { private final ChatClient chatClient; public OrderAssistantService(ChatClient.Builder builder) { this.chatClient builder .defaultSystem(你是订单售后助手回答时基于用户提供的订单信息) .build(); } public String handleOrderQuery(String orderId, String userQuestion) { return this.chatClient.prompt() .user(u - u.text(订单编号{orderId}\n用户问题{question}) .param(orderId, orderId) .param(question, userQuestion)) .call() .content(); } }这种接法的好处是把模型细节收口到一个地方Controller层根本不知道ChatClient的存在后续要替换模型实现、调整提示词策略都只改Service内部。6.2 接法二异步化处理别让模型调用阻塞主流程模型接口响应时间动辄几秒到几十秒如果同步等待用户体验和系统吞吐都会很难看。我建议对不要求即时结果的场景做成异步。利用Spring的事件机制把模型调用放到监听器里执行Component public class OrderAnalysisListener { private final OrderAssistantService orderAssistantService; EventListener public void onOrderCreated(OrderCreatedEvent event) { CompletableFuture.runAsync(() - { orderAssistantService.handleOrderQuery(event.getOrderId(), 请分析这个订单的风险点); }); } }这样做要注意两点一是异步线程池要单独配置避免把服务端的线程池打满二是模型调用的失败必须有独立的补偿或日志方案不能因为异步就静默失败。6.3 接法三超时控制与兜底不是每家模型服务商都稳定模型服务商偶尔会出现响应缓慢、超时重试的情况。不能在业务链路里无限等下去。我在ChatClient外层增加超时保护和兜底逻辑public String safeChat(String message) { return Mono.fromCallable(() - chatClient.prompt() .user(message) .call() .content()) .timeout(Duration.ofSeconds(30)) .onErrorResume(ex - Mono.just(抱歉我暂时无法处理你的请求请稍后再试。)) .block(); }超过30秒就终止等待并给用户一个友好提示。这样既保证了体验也避免了下游服务被慢调用拖垮。6.4 最后的实操体会日志里不要裸打提示词这是我踩过多次之后养成的习惯。调试时打印完整请求确实方便但日志文件一旦落到磁盘上提示词里的业务规则、可能涉及的用户隐私就都暴露了。我的做法是开发环境打印完整请求体生产环境只打印会话ID、消息长度和响应耗时必要时对消息内容做脱敏后再记录。如果你负责的项目有合规要求这一点尤其重要。与其事后删日志不如从第一天起就建立脱敏规范。毕竟模型调用里的数据和普通接口日志里的数据一样敏感甚至更敏感——因为它往往包含完整的对话上下文。这个习惯虽然和Spring AI本身无关但和大模型应用上线后的稳定、安全息息相关。希望每个接这类项目的人都能在动手之初就把它考虑进去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑