Spring AI + DeepSeek 实战:会话记忆与提示词工程详解 接手一个企业智能问答项目时最让我头疼的不是怎么把大模型接口调通而是用户连续问了三轮之后突然来一句“它怎么不记得我刚才说了什么”那一刻我才意识到接大模型只是开始真正的工程难点在于两件事怎么让模型“记住”对话以及怎么让它“读懂”你精心设计的提示词。这篇笔记整理自最近的实战项目用的是 Spring AI 1.0.x DeepSeek 的组合重点拆解会话记忆的多存储实现以及提示词工程在实际对话链路里的雕琢方法。适合正在做 AI 应用开发、需要把大模型从“玩具”变成“生产力工具”的同学参考。先说结论Spring AI 真正值钱的地方不是帮你把 DeepSeek 的 API 包了一层而是把记忆、上下文、提示词模板、输出解析这些高频痛点都抽象成了标准组件。你如果只会调用 SDK那跟写脚本没什么区别但你要是理解了 ChatMemory 和 PromptTemplate 的协作方式整个对话服务的架构会清晰很多。1. 为什么偏偏是 Spring AI DeepSeek 这个组合1.1 Spring AI 的本质是“AI 应用开发的粘合剂”很多人第一次接触 Spring AI 的时候会把它当成又一个 HTTP 客户端封装。这个理解不能说错但格局小了。Spring AI 做的事情是给大模型应用开发定义了一套标准化的组件模型。它最核心的几个抽象ChatClient统一对话入口负责把用户输入、系统提示、历史消息、工具调用组装成一次完整的请求并返回响应支持流式调用。PromptTemplate把提示词从“写死在代码里的字符串”升级成“可参数化的模板”配合Map传入变量就能动态渲染。ChatMemory管理多轮对话的历史消息解决大模型无状态的问题。Advisor在对话调用前后插入逻辑比如自动加载历史消息、自动保存本轮消息。这就像 Spring 框架本身做的事情你说“我要连数据库”它不关心你用的是 MySQL 还是 Oracle你说“我要对话记忆”它不关心你用的是内存、Redis 还是数据库。这种抽象带来的直接好处是你的业务代码可以跟具体的模型和存储解耦。今天我接 DeepSeek明天换成通义千问或者本地部署的模型改动只是配置层面的业务代码不用动。1.2 为什么选 DeepSeek 作为业务模型我见过不少团队一上来就接 GPT-4o结果项目处于原型阶段每个月 API 账单吓死人最后不得不换模型重写一遍。而 DeepSeek 在这个场景里几乎是“教科书级别”的选择。第一个原因是API 兼容 OpenAI 协议。这意味着在 Spring AI 里你完全可以通过 OpenAI 的配置项指向 DeepSeek 的地址不用等官方适配器。我自己实测过把base-url指到https://api.deepseek.com模型名填deepseek-chat请求直接通。这个兼容性对快速落地太重要了。第二个原因是中文理解和长文本能力。DeepSeek-V3 系列的上下文窗口达到 64K中文指令跟随能力在同类模型里属于第一梯队在知识问答、文案生成、数据抽取这些企业常见场景下表现很稳。而且它的 API 定价在国产模型里也属于“量大管饱”的档位适合跑业务量大的内部系统。第三个原因很现实国内部署和调用延迟更可控。对于企业内部系统数据不出境、响应速度快、备案流程简单这些都是做技术选型时要考虑的隐性成本。1.3 工程落地前必须搞清楚的版本与环境Spring AI 的版本更新节奏非常快API 变动也大。如果你搜到的是旧版本教程很可能代码复制过来直接编译不过。我这里基于 Spring AI 1.0.x 的实践整理了一个标准环境组件版本建议说明JDK17Spring Boot 3.x 硬性要求Spring Boot3.3.x 或 3.4.x与 Spring AI 1.0.x 兼容Spring AI1.0.x注意ChatMemory、MessageWindow等类名变化构建工具Maven 3.8国内建议配置阿里云镜像引入依赖的时候如果你打算走 OpenAI 兼容方式接 DeepSeek需要的是spring-ai-starter-model-openai。如果你是直接走 DeepSeek 官方适配器则引入spring-ai-starter-model-deepseek。我实际用的第一种方式因为只需要改配置就能在多个模型之间切换方便对比效果。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency这里有一个容易忽略的点Spring AI 的 BOM 依赖需要在dependencyManagement里声明版本。官方文档的做法通常是引入一个 BOM然后在依赖里不带版本号让 BOM 统一管理。如果你直接把spring-ai-bom注释掉单独引入 starter大概率会碰到版本冲突。2. 会话记忆不是“存聊天记录”那么简单从无状态到有状态2.1 大模型“失忆”的根源在于无状态设计先想一个问题大模型为什么记不住上下文因为模型的接口设计本身就是无状态的。你每次调用相当于把一份完整的对话记录打包发给它它看完之后给一个回答然后立刻“忘掉”刚才的内容。下一次你如果不把历史消息一起传过去它就什么都不记得。所以“会话记忆”的本质不是让模型记住东西而是你的应用层帮它记住然后在每次请求时把记忆重新塞给模型。这个理解非常重要因为它决定了你的实现思路。同时这里藏着一个成本问题历史消息不能无限地塞。我之前算过一笔账假设一轮简单问答大约消耗 2,000 到 3,000 Token含系统提示词用户连续聊了 50 轮如果全部回放给模型单次请求可能就要消耗 30,000 Token 以上注意是“每一轮都这样”。用户聊得越多你的 API 成本涨得越快请求延迟也越高。所以会话记忆要解决的不是“存得下”而是“存得巧、记得住、丢得起”。2.2 Spring AI 的 ChatMemory 抽象到底在管什么Spring AI 用ChatMemory接口统一了会话记忆的操作核心方法就三个public interface ChatMemory { void add(String conversationId, ListMessage messages); ListMessage get(String conversationId, int lastN); void clear(String conversationId); }add把新产生的消息用户提问 模型回答按会话 ID 存进去。get取某个会话最近的 N 条消息用于回放给模型。clear清空会话记忆。这里的conversationId是整个设计的灵魂。它在业务层通常对应“用户 ID 会话 ID”的组合用来把不同用户的记忆隔离在不同桶里。同一个用户开了新会话就给它一个新的 conversationId老会话的历史不会串场。Spring AI 在 1.0.x 中提供了一组 Advisor比如MessageChatMemoryAdvisor它们的作用相当于切面在调用模型前自动调用chatMemory.get(conversationId, lastN)把历史消息拿出来拼进上下文在模型返回后自动把“用户消息 模型回复”通过chatMemory.add存回去。你如果不用 Advisor就得自己在业务代码里每次手动做这一套代码又多又容易漏。2.3 消息窗口策略不是全量回放而是优雅遗忘既然不能全量回放就得有个淘汰策略。Spring AI 里的MessageWindow旧版本叫WindowChatMemory就是干这个的。它接收两个参数最大消息数和会话 ID 提取器。Bean public ChatMemory chatMemory() { return new MessageWindow(20); }这里20表示最多保留最近 20 条消息。20 条不是随便拍的我按一个经验公式估算每轮平均 3 条消息用户消息、模型回复、以及可能插入的工具调用结果保留最近 20 条意味着模型能看到最后 6-7 轮完整对话。对于大多数业务问答场景这个深度已经足够再早的内容模型就算看到了注意力也不一定能分配过去。而且消息窗口本身就是一种“优雅遗忘”它不是粗暴地把记录删掉而是让过时的消息自然滚出上下文窗口。用户如果问“我刚才第三轮提到的那份报表”窗口里的消息足够支撑模型回忆出来但如果用户追溯的是很久以前的内容那本来就是记忆之外的事应该靠检索系统RAG去解决而不是靠无限加窗口。3. 三种存储方案实战内存、Redis、JDBC 怎么落地3.1 内存存储原型期最省事但也最容易“重启即失忆”Spring AI 默认的InMemoryChatMemory是最简单的实现消息存在 JVM 的ConcurrentHashMap里。我刚开始做 Demo 的时候用的就是它无需任何额外配置一个Bean声明搞定Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); }注意MessageWindow本身并不负责“存储”它更像一个窗口管理器如果你想在内存里持久保持所有会话记录实际用的还是基于 Map 的实现。在 Spring AI 某些版本中MessageWindow内部也组合了内存存储逻辑。内存方案最大的问题有两个只要有多个实例每个实例各存各的用户第一次请求打到实例 A第二次请求负载均衡转到实例 B模型立刻“失忆”。这是分布式部署下的致命伤。比如用户聊了 100 轮虽然在回放时只给模型最近 20 条但内存里如果一直把所有历史的Message对象堆着几千个用户同时在线内存压力非常明显。所以内存方案适合“单机原型、本地开发、演示 Demo”这三种场景一旦上生产至少得换 Redis。3.2 Redis 存储分布式部署的标配选择到了生产环境我第一个想到的就是 Redis。理由是会话数据天然带 KeyconversationId和 TTL过期时间这和 Redis 的数据模型完美匹配。虽然 Spring AI 有官方的 Redis 相关模块但在实际项目里我更推荐自己基于RedisTemplate实现一个ChatMemory原因很实际官方实现的可定制性有限而且序列化方式不一定符合你的团队规范。下面是我在项目里实际用的一段实现Component public class RedisChatMemory implements ChatMemory { private static final String KEY_PREFIX chat:memory:; private final RedisTemplateString, Object redisTemplate; public RedisChatMemory(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; // 序列化器必须配置成支持多态否则反序列化会丢类型信息 RedisSerializer? serializer new GenericJackson2JsonRedisSerializer(); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(serializer); redisTemplate.setHashValueSerializer(serializer); } Override public void add(String conversationId, ListMessage messages) { String key KEY_PREFIX conversationId; ListMessage history getRawList(key); history.addAll(messages); redisTemplate.opsForValue().set(key, history, Duration.ofHours(24)); } Override public ListMessage get(String conversationId, int lastN) { String key KEY_PREFIX conversationId; ListMessage history getRawList(key); if (history.isEmpty()) { return List.of(); } int fromIndex Math.max(0, history.size() - lastN); return new ArrayList(history.subList(fromIndex, history.size())); } Override public void clear(String conversationId) { redisTemplate.delete(KEY_PREFIX conversationId); } SuppressWarnings(unchecked) private ListMessage getRawList(String key) { Object value redisTemplate.opsForValue().get(key); if (value instanceof List? list) { return new ArrayList((ListMessage) list); } return new ArrayList(); } }这里有个很隐蔽的坑Message里面有多种子类型用户消息、AI 消息、系统消息反序列化时必须能还原出正确的类型。所以上面代码里专门把 ValueSerializer 配成了GenericJackson2JsonRedisSerializer否则你存进去的时候是AssistantMessage取出来变成了LinkedHashMapAdvisor 直接报类型转换错误。TTL 我设的是 24 小时适合大多数业务场景。如果你的产品要求会话状态保留得更久可以把这个值调大或者改成固定 Key 不上过期时间由后台任务定期清理。3.3 JDBC 存储当会话需要落库审计时Redis 适合高速存取但它有一个问题不便于做复杂的查询和分析。如果你们是客服系统、在线问诊、金融咨询这类强审计场景每一个对话都要能回溯、能按条件检索那就轮到 JDBC 存储出场。基于 JDBC 实现 ChatMemory核心是建一张表通常包含这些字段CREATE TABLE IF NOT EXISTS chat_memory ( id BIGINT AUTO_INCREMENT PRIMARY KEY, conversation_id VARCHAR(128) NOT NULL, message_type VARCHAR(32) NOT NULL, message_content TEXT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_conversation (conversation_id, create_time) );存储的时候把 List 里的每一条 Message 拆成一行记录它的类型和内容读取的时候按 conversationId 倒序取最近 N 条。相比 RedisJDBC 方案多了一次序列化和 IO 开销单次请求的延迟会高几毫秒但换来的是可以随时写 SQL 查“某个用户三天前到底聊了什么”这种运营问题。多轮对话超过窗口长度时读取最近 50 条数据晚于窗口之外的消息留在库里只是不再回放给模型。这样既不丢审计数据又控制住了上下文成本。3.4 三种方案对比与选型建议方案持久化多实例共享定位适用场景内存无不支持原型验证本地开发、单机 DemoRedis有受 TTL 控制支持生产标配大多数在线会话应用JDBC有永久保留支持强审计客服、金融、法律等合规场景选型建议我总结成一句话不知道选什么就选 Redis有合规审计要求就上 JDBC或者把 Redis 作为缓存、JDBC 作为归档层同时使用。我在实际项目中就是这种组合Redis 负责快速读写每天凌晨跑任务把过期会话归档到数据库两边都不耽误。4. 提示词工程让 DeepSeek 稳定输出理想答案的雕琢方法4.1 提示词工程本质上是在定义“模型的岗位说明书”很多人以为提示词工程是玩文字游戏写几句“你是一个聪明的助手”就完事。实际上我做了十几个项目之后的体会是提示词工程是在给模型写岗位说明书。一份完整的岗位说明书必须回答五个问题你是谁、任务是什么、边界在哪里、输入格式是什么、输出格式是什么。拿我做过的一个合同风险审查助手举例。一开始的提示词只有一句“请审查合同并给出意见”结果模型给出的意见又空泛又格式混乱有时候甚至只是在复述合同原文。我把这五个要素全部写清楚之后效果立刻不一样模型会主动按“风险条款”“修改建议”“严重程度”三个维度输出而且能套用标准的法律术语。你可以把提示词理解成给模型划清楚了一个“能力圈”圈内的内容它给出稳定输出圈外的内容它知道直接承认不会。边界划得越清楚模型发挥就越稳定。4.2 Spring AI 中的 PromptTemplate 与参数化提示词不能写死在代码里因为同一条提示词在不同场景下要插入不同的业务数据。Spring AI 的PromptTemplate解决了这个参数化的问题。Service public class LegalReviewService { private final ChatClient chatClient; public LegalReviewService(ChatClient.Builder builder) { this.chatClient builder.build(); } public String reviewContract(String contractContent, String reviewFocus) { String systemPrompt 你是一名资深企业法务顾问拥有十年以上合同审查经验。 任务审查用户提供的合同条款输出审查意见。 要求 1. 只输出合同中有风险的内容不要复述合同原文。 2. 按【风险等级】/【风险点】/【修改建议】三段式输出。 3. 如果合同内容不完整明确说明缺少哪些要素不要猜测。 本次审查重点{focus} ; return chatClient.prompt() .system(s - s.text(systemPrompt) .param(focus, reviewFocus)) .user(contractContent) .call() .content(); } }这里的{focus}是模板占位符运行时通过param方法填充。这样做最大的好处是提示词和代码逻辑分离产品经理想调“审查重点”的时候只需要改传参不需要动代码。如果你把提示词放到配置中心那就能做到不改代码、不重启服务就调优模型行为。4.3 Few-shot 少样本与输出格式约束大模型虽然强但你不给它例子它的回答总是会带点“自由发挥”的成分。少样本提示Few-shot就是在提示词里直接给两三个输入输出对让模型照着这个模式输出。我用一个实体抽取的例子来说明String fewShotPrompt 从用户输入的文本中抽取客户信息输出 JSON字段包括name姓名、company公司、role职位。 如果无法确定某个字段填 null。 示例1 输入3月12日阿里巴巴的副总裁张伟到访我们公司洽谈云计算合作。 输出{name: 张伟, company: 阿里巴巴, role: 副总裁} 示例2 输入李芳说她会把报价单发到我的邮箱。 输出{name: 李芳, company: null, role: null} 现在处理下面的输入 输入{userInput} 输出 ; String response chatClient.prompt() .user(u - u.text(fewShotPrompt).param(userInput, userInput)) .call() .content();实际跑下来DeepSeek 对这个格式的遵循度非常高。我觉得原因是示例给了模型一个具体的“锚点”让它知道输出风格是什么、字段怎么填而不是让它凭空想象。注意几个细节示例数量 2 个足够太多反而浪费 Token示例要和真实场景尽量接近JSON 字段不要用中文命名否则在 Java 侧解析又得做一层映射。4.4 参数调优temperature 与提示词的配合提示词只是决定输出质量的一半另一半在模型参数里。我在 DeepSeek 上最常用的调参就是temperature它控制的是采样随机性。场景temperature 建议原因代码生成、SQL 生成0.0 - 0.2结果要确定不允许自由发挥文档摘要、实体抽取0.2 - 0.4严格遵循原文少量润色文案写作、头脑风暴0.7 - 1.0需要多样性和创造力在 Spring AI 里可以在每个请求里单独设置ChatResponse response chatClient.prompt() .system(你是一个严谨的代码评审专家只输出具体的修改建议不要输出空泛的赞扬。) .user(codeSnippet) .options(OpenAiChatOptions.builder() .withTemperature(0.1) .withMaxTokens(2048) .build()) .call();我的经验是如果提示词已经足够结构化temperature 调低一点模型的输出会明显更“听话”反过来如果提示词本身边界不清楚光靠调低温度也不能解决跑偏问题得先回头补提示词。5. 一个完整可跑的 DemoChatMemory DeepSeek 串起来5.1 依赖与基础配置到了这一步我们把前面的东西拼成一个能跑的完整示例。首先在pom.xml里声明依赖区域省略了版本号建议用 BOM 统一管理dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后在application.yml里配置 DeepSeek 的 OpenAI 兼容地址spring: application: name: spring-ai-demo ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.3 max-tokens: 2048api-key我推荐从环境变量读取不要硬编码进配置文件。这个习惯在多人协作的项目里能省掉很多安全事故。5.2 配置类把 ChatMemory 与 Advisor 组装起来接下来组装会话记忆能力。这里有一个容易迷惑的点ChatClient不是配置一个 Bean 就完事你还需要给它挂上MessageChatMemoryAdvisor它才会在每次对话时自动处理记忆。Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory() { // 这里可以替换为 RedisChatMemory 或 JdbcChatMemory return new MessageWindow(50); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { MessageChatMemoryAdvisor memoryAdvisor new MessageChatMemoryAdvisor(chatMemory); return builder .defaultAdvisors(memoryAdvisor) .defaultSystem(你是一个乐于助人的智能助手回答要简洁、准确、友好。) .build(); } }MessageChatMemoryAdvisor默认会从请求参数里读取conversationId如果没有就生成一个新的。也就是说前端每次请求必须把同一个会话 ID 传过来记忆才能串联上。这个默认行为很多时候不是我们想要的我建议显式定义一个参数解析器。5.3 Controller 层会话 ID 的传递方式我的做法是写一个简单的 DTO前端把conversationId和message一起传过来RestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/memory) public MapString, String chatWithMemory(RequestBody ChatRequest request) { String content chatClient.prompt() .user(request.message()) .advisors(a - a.param(conversationId, request.conversationId())) .call() .content(); return Map.of(reply, content); } public record ChatRequest(String conversationId, String message) {} }代码里的关键是.advisors(a - a.param(conversationId, request.conversationId()))。如果不传这个参数Advisor 每次都会生成一个新的会话 ID记忆永远拼不上。我在很多博文里看到有人只挂了 Advisor 没传会话 ID然后跟我抱怨“怎么还是失忆”大概率就是漏了这一步。5.4 测试效果连续对话验证启动应用后用 curl 测一下curl -X POST http://localhost:8080/api/chat/memory \ -H Content-Type: application/json \ -d {conversationId: test-001, message: 我叫张三是采购部的} curl -X POST http://localhost:8080/api/chat/memory \ -H Content-Type: application/json \ -d {conversationId: test-001, message: 我是哪个部门的}第二问如果能回答出“采购部”说明记忆链路已经通。这里我建议你再验证一件事用另一个会话 ID 发同样的第二问正常情况模型应该给不出答案因为两个会话的记忆是隔离的。会话隔离如果没测就是埋雷。6. 实测中遇到的问题与我的处理经验6.1 消息窗口的 Token 爆炸我差点烧光额度第一次上线的时候我把MessageWindow的窗口设成了 80想着“多保留点模型记忆更好”。结果跑了半天发现 API 账单猛涨单个请求偶尔还会报上下文超长错误。复盘的时候我算了笔账DeepSeek 的上下文上限是 64K Token但 Spring AI 的调用链路是“系统提示词 历史消息 当前提问”拼在一起。系统提示词我写得很长大约 1,500 Token历史消息每条平均 300-500 Token窗口 80 条意味着光历史就接近 32,000 Token。一旦用户在某个大数据量场景下提问很长请求直接超限。后来我把窗口调小到 20并给系统提示词“瘦身”把那些啰嗦的背景说明全部删掉只保留百分之百必要的任务描述、角色设定、输出格式。调整后的效果是单次请求稳定在 8,000-12,000 Token响应更快成本直接降了一半以上。这里给你一个经验公式窗口大小 (上下文上限 - 系统提示词长度 - 预估单轮最大用户输入长度) ÷ 平均单条历史消息长度。按这个公式算出来的窗口大小既不会超限也能保证足够的上下文深度。6.2 Redis 换存储后 Key 过期导致的“失忆”乌龙有一次我把存储从内存换成 Redis本地测得好好的上到测试环境之后用户聊到一半就“失忆”了。检查了半天才发现测试环境的 Redis 里配了全局默认过期时间 30 分钟而我们的会话设计里用户经常会隔一两个小时回来继续问。这个问题比较隐蔽因为 Redis Java 客户端里如果你set的时候没显式传过期时间它会用 Redis 服务端的默认策略。而本地开发环境通常没有这个默认过期所以本地永远测不出来。解决方式就两条存储时显式传入Duration我上面代码里写的Duration.ofHours(24)就是这么来的再一个是在 Redis 配置文件里明确spring.data.redis.timeout这些参数不要依赖服务端默认。6.3 占位符解析的隐蔽陷阱与我的排查思路提示词模板用熟了之后我遇到过一个非常诡异的 Bug系统提示词里写了{focus}占位符运行时产品经理反馈“模型有时候像没看到这个重点一样”。我一开始以为是模型能力问题后来打日志才发现有时候focus参数没传进去模板渲染出来的字符串里直接保留了{focus}原样模型看到的是“本次审查重点{focus}”这种残缺指令。定位过程并不复杂先打印渲染后的真实 Prompt跟预期对比然后检查所有调用入口是否有遗漏param方法。但这个问题真正让人头疼的地方在于它不是必现的只有某个业务分支没走到param赋值时才触发。我的建议很实用在开发环境加一个 AOP 切面拦截所有chatClient.prompt()调用把最终发给模型的 prompt 渲染结果打印出来。一旦发现 prompt 里有{残留立刻就能定位到是占位符没替换。另外模板里宁可多写几个预设变量也不要让用户输入直接拼接到模板字符串里否则用户输入里的花括号也可能干扰占位符解析。最后再分享一个小经验会话记忆和提示词工程这两块一定要在做接口联调之前就设计好不要等模型响应都通了再去补。因为这两块会直接影响你的 prompt 结构、请求参数设计和存储模型早期改的成本极低后期改就像给已经封顶的楼加一层牵一发动全身。我第一次做的时候就是先通 API 再补记忆结果 Controller、Service、配置全部返工白白折腾了两天。先把记忆链路用内存方案跑通把提示词模板在本地调稳再上 Redis、再调参这个顺序是最稳的。