
做社区志愿助手这个项目Day1 把 Spring AI 的接入和基础问答跑通了当时还挺得意。结果 Day2 一上来就碰了个很现实的问题AI 不记事。用户上午问过爱心食堂的开放时间下午再问“那我想去帮厨该找谁”它就跟失忆了一样完全接不上话茬。这个问题的学名就是会话记忆也就是让大模型在多次独立请求之间保留上下文。Spring AI 官方把这类能力封装成了 ChatMemory 和 Advisor 两套机制用起来不算复杂但里面坑不少尤其是上下文怎么存、存多久、窗口开多大每一处都直接影响体验和成本。这篇复盘就专门聊聊我在志愿助手项目里落地会话记忆的完整过程包括方案选型、核心代码、参数取舍和几个典型问题给正在用 Spring AI 做 Agent 或聊天机器人的朋友做个参考。这个项目本身是给社区志愿者用的场景很杂有人问活动报名流程有人查服务对象档案还有社工想快速生成探访记录。共同点是对话都带有强烈的连续性如果模型每次把用户当陌生人那这个助手基本就废了。所以 Day2 的目标非常明确让助手“记住”同一会话内聊过什么并且能主动引用前文信息。适合来读这篇内容的朋友大致是两类一类是刚把 Spring AI 跑通、正准备加记忆功能的新手另一类是已经在生产环境用了 ChatClient、想看看别人怎么处理窗口和持久化问题的开发者。下面进入正题先把会话记忆的设计思路拆开讲清楚。1. 为什么社区志愿助手绕不开会话记忆1.1 用户场景决定了“失忆”不可接受志愿者的日常咨询和普通聊天不一样它不是一问一答就结束的。我拿项目里一个真实场景举例有位阿姨在公众号里问“周末的社区义诊几点开始”助手回答了“周六上午九点在党群服务中心一楼”。过了十分钟她又问“那我需要带什么材料吗”模型如果没有记忆就会把上一句完全丢掉重新回答一遍“请问您指的是什么活动”阿姨大概率会觉得这系统是个智障。这类多轮交互在所有社区服务场景里都是常态。活动报名往往要经过“查时间—确认地点—提交信息”三四个来回探访记录整理更是需要用户分多次补充细节。也就是说会话记忆不是一个可选项而是这个助手能否真正产生价值的基础能力。没有记忆的 AI 助手本质就是个带自然语言界面的搜索引擎这跟项目的目标完全背离。1.2 Spring AI 的请求-响应模型天然无状态Spring AI 底层调用大模型 API 时每次请求都是独立的 HTTP 调用服务端并不会因为你是同一个用户就自动带上历史消息。这跟我们写 HTTP 接口是一样的服务端无状态意味着可水平扩展但也意味着所有上下文都得由应用层自己拼接。官方 ChatClient 接口在设计上把“构建 Prompt”和“调用模型”解耦了Prompt 里 messages 列表就是模型能看到的所有内容而会话记忆的核心工作就是把历史消息按某种策略塞回这个 messages 列表里。理解这一点特别关键。很多人一开始以为 Spring AI 有个开关能一键开启记忆实际上没有。官方提供的是组件不是魔法。底层逻辑是你每次请求时把适合的对话历史取出来拼到当前用户消息前面一起发给模型。会话记忆的所有实现本质上都是在做这件事只不过做得好不好、性能高不高、会不会爆 token差别很大。1.3 从 Day1 到 Day2 的能力演进Day1 我搭的是一个无状态的 ChatClientsystem prompt 里写死了一堆志愿服务的规则用户问什么就答什么互不干扰。功能跑通了但测试时明显感觉不对劲你说“帮我预约明天上午十点的场地”它说“好的已预约”你接着问“帮我改到下午三点”它直接懵了因为它不记得刚才约过。这种体验要是放出去给社区老人用基本就是劝退。Day2 加入会话记忆后整个助手的交互质量上了一个台阶。用户不用每次都把前因后果重复一遍模型能根据上下文理解指代、承接话题。更关键的是这为后续做“志愿档案总结”“服务时长自动统计”这类复杂 agent 能力打了底。没有会话记忆后面的工具调用、多轮任务拆解都是空谈。所以这一天的内容其实是整个项目从“会说话”到“会聊天”的分水岭。2. 方案选型Spring AI 的 ChatMemory 与 Advisor2.1 ChatMemory 接口和三种内置实现Spring AI 官方抽象了一个 ChatMemory 接口核心方法就两个put 写入一段消息get 按 conversationId 取出一段历史。接口本身非常简单但官方在 starter 里已经内置了几种可用实现我简单列一下实现存储位置特点适合场景MessageWindowChatMemory内存按条数滑动窗口超出的旧消息自动丢弃单机、短会话、原型验证VectorStoreChatMemory向量数据库按相似度召回历史理论上无限记忆需要“长期记忆”或主题检索JdbcChatMemory / RedisChatMemory数据库持久化存储天然支持多实例共享生产环境、多节点部署MessageWindowChatMemory 是默认也不动脑子的选择用起来非常省事。但它的局限也很明显只保留最近 N 条消息一旦超出窗口早期信息就没了。VectorStore 看起来很美但需要注意它召回的是“语义相似”的历史片段不是严格按时间排序的完整对话用在这种需要精准承接上下文的场景里反而可能答非所问。所以我在志愿助手项目里最终选了 JdbcChatMemory 作为主方案开发初期为了快先用 MessageWindow 顶着后面再切。2.2 Advisor 机制把记忆注入变成声明式配置ChatMemory 本身只是存储真正把历史消息拼进 Prompt 的工作由 Advisor 完成。Spring AI 里的 Advisor 概念可以理解为一种“请求拦截器”在调用大模型之前对 prompt 做加工。官方提供的 MessageChatMemoryAdvisor 就是这个用途它负责从 ChatMemory 里按 conversationId 取历史消息拼到当前消息前面同时把当前这轮的用户消息和助手回复写回 ChatMemory。用 Advisor 的好处是你的业务代码不需要关心消息拼接的细节只要在构建 ChatClient 时声明一个 defaultAdvisor剩下的框架全给你干了。这非常贴合 Spring Boot 的开发习惯配置优先约定大于配置。我当时第一版是自己手动拼 history 再调 prompt后来发现多会话并发的时候拼接逻辑容易写乱改用 Advisor 之后代码干净了不止一星半点。2.3 为什么要选 Spring AI Alibaba 的增强实现项目热词里出现了 Spring AI Alibaba我额外多说一嘴。社区志愿助手如果想接国内大模型DashScope通义千问是个很自然的选择而 spring-ai-alibaba 这个项目把这层封装做得比较到位。它同样实现了 ChatMemory 和 Advisor但针对 DashScope 的返回格式、token 计费方式做了适配尤其是它支持 DashScope 服务端的一些记忆扩展能力跟原生 Spring AI 的 API 基本兼容。我个人的建议是如果你的项目确定要跑在国内云上直接走 spring-ai-alibaba 的 starter代码结构跟你用原生 Spring AI 几乎一样但省去不少兼容性调试。我项目里为了稳妥先按原生 Spring AI 接口写好业务代码再用 alibaba 的包替换底层实现迁移时没有遇到大问题。这块后面实操部分会体现出来。3. 核心细节窗口大小、会话 ID 与 Token 预算3.1 MessageWindow 的 windowSize 到底设多大很多教程会直接让你把 windowSize 设成 20、50但闭眼设数字一定会踩坑。窗口大小的核心约束不是“够不够用”而是“模型上下文窗口减去 system prompt 和当前问题后还剩多少”。一份带志愿者规则的 system prompt 大概消耗 500~800 token用户的问题假设 100 token模型回答预留 500 token如果模型上下文是 8k token那留给历史消息的空间大约是 6k token。中文会话平均每条消息用户助手大概 200~300 token算下来 20 到 30 条比较合适。我一开始把 windowSize 调成 50结果请求量一大总是报 context length exceeded一查日志才发现历史消息加系统提示已经超出上下文限制了。后来改成 20并且必须把 system prompt 精简体验反而更稳定。个人习惯是先做一次真实对话数一下平均 token 消耗再倒推窗口大小别拍脑袋。3.2 conversationId 怎么生成与管理会话记忆的存取都靠 conversationId 这个钥匙。在社区志愿助手场景里用户通过公众号或小程序进来一个用户可能发起多个会话所以不能简单用 userId 当 conversationId。我这边是每次对话开始时生成一个 UUID 作为 sessionId存在前端之后每次请求都带上。如果用户在 30 分钟内没有新消息就新开一个会话如果有连续交互就沿用旧 id。这里有个细节容易忽略MessageChatMemoryAdvisor 获取 conversationId 的方式是从请求参数里取参数名默认是 conversationId。如果你走 HTTP 接口得确保每次请求的入参里都有这个字段不然 Advisor 会直接抛异常。我一开始没注意前端没传这个 id结果接口一直 500排查了半天才发现是这里。后来我在 Controller 层加了个兜底如果前端没传 conversationId就用 userId 当天日期生成一个保证不崩。3.3 Token 成本与响应速度的平衡会话记忆本质上是在用 token 换上下文。历史消息拼得越多模型理解越准但每次调用的成本也越高响应时间越长。社区志愿助手这种场景用户对响应速度比较敏感等三五秒还能忍超过十秒就有点劝退。我实测下来当历史消息在 20 条以内时通义千问的响应时间基本稳定在 2~3 秒一旦超过 40 条响应时间会明显上涨到 5 秒以上而且费用肉眼可见地增加。所以生产环境不能只依赖窗口大小还应该叠加一个摘要策略。简单说就是保留最近 N 条完整消息对于更早的对话每隔几轮让模型生成一段摘要把摘要也放回记忆里。Spring AI 有 PromptTemplate 可以做这件事但官方没有一键集成的组件需要自己写一个定时压缩的 advisor。这个我放在第四部分展开讲因为它是从 demo 走向生产的必经一步。4. 实操落地在志愿助手里接入会话记忆4.1 引入依赖与基础配置我用的环境是 Spring Boot 3.3.x Spring AI 1.0.0 GA数据库是 PostgreSQL。为了能跑通流程我同时引入了 spring-ai-starter-model-dashscope 和 spring-ai-starter-memory-jdbc 两个依赖。如果你直接用 OpenAI 的话把 dashscope 的依赖换成 openai 的 starter 就行其他逻辑没有区别。dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId version1.0.0-M6.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-memory-jdbc/artifactId version1.0.0/version /dependency配置文件的重点是开启 jdbc memory 的建表能力以及设定默认的参数spring.ai.chat.memory.enabledtrue spring.ai.chat.memory.conversation-idconversationId spring.ai.chat.memory.window-size20 spring.ai.datasource.urljdbc:postgresql://localhost:5432/volunteer spring.ai.datasource.usernamepostgres spring.ai.datasource.passwordyourpassword注意 spring-ai-alibaba 的版本号要跟 Spring Boot 版本匹配我一开始用了旧版本跟 Spring Boot 3.3 的自动配置冲突直接起不来。这块要养成看官方 release notes 的习惯。4.2 配置 ChatMemory 的 Bean官方 starter 会自动装配一个 JdbcChatMemory如果对默认配置不满意也可以自己定义 Bean 覆盖。我这边为了能打印日志观察历史消息内容选择手动定义了一个 MessageWindowChatMemory 先做本地验证后面再替换成 Jdbc 版本Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); } }如果切到 JdbcChatMemory则不需要手动创建 Bean直接在配置里指定数据源即可。JdbcChatMemory 会自动创建 ai_chat_memory 表每次 put 的时候往表里插记录get 的时候按 conversationId 查最近 N 条。需要注意的是这个表的索引一定要建立在 conversationId 上数据量大了之后不带索引的查询会非常慢。我项目上线第一天数据量小没感觉第二天一压测就暴露了后来手动补了索引才解决。4.3 用 Advisor 构建带记忆的 ChatClient这是整个落地过程的核心代码。我用 ChatClient.Builder 构建客户端时加了一个 MessageChatMemoryAdvisorConfiguration public class ChatClientConfig { private final ChatMemory chatMemory; public ChatClientConfig(ChatMemory chatMemory) { this.chatMemory chatMemory; } Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem( 你是社区志愿助手的智能助理负责回答志愿者关于活动报名、服务记录、探访安排等问题。 请保持回答简洁、有温度。如果用户提到之前聊过的话题请主动结合上下文回答。 ) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } }然后在 Controller 里直接调用RestController RequestMapping(/api/chat) public class VolunteerChatController { private final ChatClient chatClient; public VolunteerChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping public MapString, String chat(RequestBody ChatRequest request) { String reply chatClient.prompt() .user(request.message()) .adults(a - a.param(conversationId, request.conversationId())) .call() .content(); return Map.of(reply, reply); } }这里的一个关键点是conversationId 必须通过 param 传给 advisor否则 advisor 不知道从哪个会话取历史。我最初以为直接放到 user message 里就行结果发现 advisor 读的是 param不是 message。这个坑卡了我将近一个小时希望能帮后面的人少走弯路。4.4 验证记忆是否生效的测试方法写完代码先别急着接前端我习惯用 curl 直接验证。第一次请求传入 conversationIdtest-001问“今天社区义诊几点开始”第二次请求传同一个 id问“那地点在哪”如果第二次回答里包含“义诊”或第一次对话的信息说明记忆生效了。curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {conversationId:test-001,message:今天社区义诊几点开始}curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {conversationId:test-001,message:那地点在哪}如果第二条回复直接说“请问您指的是什么活动”那就说明历史消息没有拼进去。这时候优先检查两件事一是 ChatMemory 表里有没有写入记录二是 advisor 有没有正确拿到 conversationId 参数。我用这种二分法排查基本能在五分钟内定位问题。4.5 把内存版切换成 JDBC 持久化版验证完内存版没问题我再把 ChatMemory 的 Bean 注释掉依赖自动装配的 JdbcChatMemory。切换后重启应用第一次请求时框架会自动创建表结构然后一切照常。这一步看似简单但要注意一个坑JdbcChatMemory 存储的消息是按 JSON 序列化存到数据库的如果你的 model 返回的消息类型跟序列化工具不兼容会出现读出来是空的情况。我用的默认 Jackson 没遇到问题但如果换了 fastjson 之类的工具需要额外配一下序列化器。切换持久化之后多实例部署就顺理成章了。所有实例共用同一个数据库conversationId 对应的历史消息存在表里任何一台机器处理同一个会话都能读到完整上下文。对于社区志愿助手这种低并发但要求可靠的服务这个方案足够用而且比 Redis 少维护一个中间件。5. 踩坑记录窗口溢出、并发写入与上下文丢失5.1 token 超限问题的排查思路把会话记忆接上之后我做的第一轮压力测试就爆了。问题表现是连续对话超过二十轮之后接口开始报错日志里出现“maximum context length exceeded”之类的提示。前面也说过这本质是历史消息 system prompt 当前消息的总 token 数超过了模型上限。排查方法是先看日志里实际发送的 prompt 长度再看模型上下文上限算一下差多少最后把 windowSize 调小到安全范围。另外还有一种隐蔽情况助手的回复本身特别长如果把每次回复都原封不动存进记忆很快就把窗口填满了。我后来在写入记忆之前对助手的回答做了一个简单处理超过 500 字就截断或要求模型压缩后再写进去。这个属于经验优化但效果非常明显同样的 windowSize 能支撑的对话轮数几乎翻倍。5.2 并发场景下的消息写入顺序问题社区志愿助手虽然并发量不大但同一个用户如果连发两条消息后端可能同时处理两个请求。这两个请求同时读取 ChatMemory拿到的是相同的历史然后又同时写入导致后写的覆盖先写的出现上下文丢失。这个问题在 MessageWindowChatMemory 里尤其明显因为它是纯内存操作没有锁。我要处理这个问题最直接的手段是给同一个 conversationId 的请求加分布式锁或者至少加 JVM 内锁。实现上我没有引入 Redisson而是简单地在服务里加了 ConcurrentHashMap 的锁对象private final MapString, Object locks new ConcurrentHashMap(); private Object getLock(String conversationId) { return locks.computeIfAbsent(conversationId, k - new Object()); }处理完用户的请求之后再同步 put 消息。对志愿助手这个体量的项目来说这个方案够用但如果未来做高并发还是要上 Redisson 或者把写入改成队列串行化。5.3 模型“忘记”早期信息的替代方案滑动窗口最大的毛病就是冷启动问题新会话说得好好的一旦窗口滚动早期关键信息比如用户已经报过名、已经留过电话号码就没了模型会重复询问已经提供过的信息。这在社区场景里特别尴尬老人会觉得“我不是刚说过吗你怎么还问”。我后来的应对方式是引入摘要记忆。具体做法是每一轮对话结束后如果历史消息超过了窗口的一半我会用一个小模型调用 PromptTemplate 把之前的对话总结成三到五条要点连同最近的消息一起放回 Memory。这个功能官方没有直接给但自己实现并不复杂本质就是多一次模型调用把摘要文本 prepend 到 prompt 里。public String summarize(ListMessage history) { PromptTemplate template new PromptTemplate( 请用简洁的中文总结以下对话的关键信息包括用户的需求、已经确认的事项、待办事项。输出不超过200字。 {history} ); return chatClient.prompt() .user(template.create(Map.of(history, history)).getContents()) .call() .content(); }实测下来加了摘要之后连续对话超过 50 轮模型依然能记住“用户已预约周一上午九点探访独居老人”这样的关键信息。虽然多了一次 token 调用但换来的体验提升非常值得。这算是会话记忆从“能用”走向“好用”的关键一步。5.4 多轮工具调用时的记忆联动项目后续接了工具调用日历查询、场地预约这时会话记忆的意义就更大了。比如用户让助手查一下“周五下午有没有空闲活动室”助手调用工具查到结果后这个结果会作为 assistant 消息存进记忆。用户接着问“那就订这间吧”模型需要结合之前工具返回的“房间号 A302”才能完成预约。这里有个 Spring AI 的细节工具调用的中间过程也就是 function call 和 function result都会以消息形式进入 ChatMemory但它们的内容可能很长。我建议对这个类型的消息做裁剪只保留工具名、关键参数和返回值摘要避免工具返回的一大坨 JSON 撑爆窗口。实测中一个复杂的场地查询接口能返回 2000 多字 JSON不加处理的话两三次工具调用就够了。6. 复盘总结与后续优化思路6.1 本次迭代的关键成果Day2 结束时志愿助手已经具备了一个可用的会话记忆能力。用户连续咨询活动信息时助手能准确承接上下文多实例部署时会话状态通过数据库共享窗口大小经过压测调整在成本和体验之间找到了平衡点。我把这些改动提交到代码库后给几位同事做了演示大家最直观的感受就是“它像在认真听你说话了”。6.2 尚未解决的问题与下一步计划会话记忆目前的形态还是“短时记忆”只保证同一对话内的连续性。真正意义上的长期记忆比如记住这个用户是位经常参与探访活动的退休教师、偏好周六上午服务这种跨会话的画像记忆还没有引入。Spring AI 提供了 VectorStoreChatMemory 和相关的向量化能力理论上可以做到“用户画像记忆”这部分我准备放到后续的 Day3 版本里做。另外JdbcChatMemory 的存储结构是 key-value 式的会话数据量大了以后查询性能和存储成本会成为问题。到时候可能需要把归档的旧会话定期导出或者切到专业的向量数据库。这些属于运维层面的优化需要根据项目规模再具体评估。6.3 给同样在做 Spring AI 项目的人几点建议如果你正在做类似的项目我的建议是不要一上来就追求复杂的记忆策略。先把 MessageWindowChatMemory 跑通用真实业务场景验证交互效果再逐步叠加摘要、向量检索和工具联动。会话记忆的核心在于理解你的用户到底需要记住什么而不是把技术栈堆得越高越好。另外conversationId 的规范要提前定好别等到多端接入的时候再改那会牵连到前端、网关、埋点好几个地方。我自己实测下来Spring AI 的会话记忆这个方向官方文档其实写得比较精简很多细节需要靠实战去踩。像 advisor 的 param 传递、JDBC memory 的建表时机、并发写入的顺序问题都是文档里不会明确写但实际必踩的坑。希望这篇复盘能帮你少走几步弯路也欢迎有类似经验的朋友一起交流。