☰
Spring AI + DeepSeek 实战:会话记忆与提示词工程详解
2026/10/18 11:09:14 网站建设 项目流程

接手一个企业智能问答项目时,最让我头疼的不是怎么把大模型接口调通,而是用户连续问了三轮之后,突然来一句:“它怎么不记得我刚才说了什么?”那一刻我才意识到,接大模型只是开始,真正的工程难点在于两件事:怎么让模型“记住”对话,以及怎么让它“读懂”你精心设计的提示词。这篇笔记整理自最近的实战项目,用的是 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 的实践整理了一个标准环境:

组件版本建议说明
JDK17+Spring 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> <groupId>org.springframework.ai</groupId> <artifactId>spring-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, List<Message> messages); List<Message> 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。理由是会话数据天然带 Key(conversationId)和 TTL(过期时间),这和 Redis 的数据模型完美匹配。

虽然 Spring AI 有官方的 Redis 相关模块,但在实际项目里,我更推荐自己基于RedisTemplate实现一个ChatMemory,原因很实际:官方实现的可定制性有限,而且序列化方式不一定符合你的团队规范。下面是我在项目里实际用的一段实现:

@Component public class RedisChatMemory implements ChatMemory { private static final String KEY_PREFIX = "chat:memory:"; private final RedisTemplate<String, Object> redisTemplate; public RedisChatMemory(RedisTemplate<String, 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, List<Message> messages) { String key = KEY_PREFIX + conversationId; List<Message> history = getRawList(key); history.addAll(messages); redisTemplate.opsForValue().set(key, history, Duration.ofHours(24)); } @Override public List<Message> get(String conversationId, int lastN) { String key = KEY_PREFIX + conversationId; List<Message> 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 List<Message> getRawList(String key) { Object value = redisTemplate.opsForValue().get(key); if (value instanceof List<?> list) { return new ArrayList<>((List<Message>) list); } return new ArrayList<>(); } }

这里有个很隐蔽的坑:Message里面有多种子类型,用户消息、AI 消息、系统消息,反序列化时必须能还原出正确的类型。所以上面代码里专门把 ValueSerializer 配成了GenericJackson2JsonRedisSerializer,否则你存进去的时候是AssistantMessage,取出来变成了LinkedHashMap,Advisor 直接报类型转换错误。

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 条。相比 Redis,JDBC 方案多了一次序列化和 IO 开销,单次请求的延迟会高几毫秒,但换来的是可以随时写 SQL 查“某个用户三天前到底聊了什么”这种运营问题。

多轮对话超过窗口长度时,读取最近 50 条数据,晚于窗口之外的消息留在库里,只是不再回放给模型。这样既不丢审计数据,又控制住了上下文成本。

3.4 三种方案对比与选型建议

方案持久化多实例共享定位适用场景
内存无不支持原型验证本地开发、单机 Demo
Redis有(受 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. 一个完整可跑的 Demo:ChatMemory + DeepSeek 串起来

5.1 依赖与基础配置

到了这一步,我们把前面的东西拼成一个能跑的完整示例。首先在pom.xml里声明依赖(区域省略了版本号,建议用 BOM 统一管理):

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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: 2048

api-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 Map<String, 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、再调参,这个顺序是最稳的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询