ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

如果你是一个常年用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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-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 Flux<String> streamChat(String userMessage) { return this.chatClient.prompt() .user(userMessage) .stream() .content(); }

在Controller里直接返回这个Flux,Spring WebFlux会自动按SSE的方式把内容推给前端:

@RestController public class ChatController { private final ChatAssistantService chatAssistantService; public ChatController(ChatAssistantService chatAssistantService) { this.chatAssistantService = chatAssistantService; } @GetMapping("/chat/stream") public Flux<String> 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参数传入会话ID:

public 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本身无关,但和大模型应用上线后的稳定、安全息息相关。希望每个接这类项目的人都能在动手之初就把它考虑进去。

返回列表