1. 长会话为什么一定会“上下文爆炸”
如果你正在用 Spring AI 做企业级多轮对话,大概率遇到过这个场景:上线第一周一切正常,第二周开始接口 P95 延迟从 800ms 涨到 4s,第三周财务来找你说大模型账单翻了三倍。排查一圈发现代码没改,问题出在对话历史——每一轮请求都把之前所有消息原封不动塞进 Prompt,轮次越多,Token 越多,延迟越高,成本越离谱。
这就是典型的上下文爆炸。Spring AI 原生给的MessageWindowChatMemory只做一件事:设定一个最大消息条数,超了就从头删。逻辑简单,但生产上很致命。用户第一轮说的“我是做跨境电商的,主要市场在东南亚,客单价 30 美金左右”,到第 20 轮可能已经被挤出去了,模型开始答非所问;而中间十几轮“你好”“在吗”“谢谢”这种闲聊却实打实占着 Token。
所以企业级方案要解决的不是“存不存记忆”,而是“怎么聪明地取舍记忆”。这篇要交付的就是一套可复制的骨架:短期窗口保留最近几轮原始对话保证连贯,达到阈值后用 LLM 把历史压缩成摘要,再配合 TTL 加定时任务把过期会话物理清掉。目标很明确——把单次请求的 Token 控制在一个稳定区间,同时核心信息不丢。
整套方案基于 Spring AI 1.0 官方原生 API,不引入任何灰色组件,模型调用走标准 OpenAI 兼容接口。下面从依赖配置一路写到验证步骤,你可以直接照着改。
2. 前置准备:模型接入与 Key 获取
在写记忆逻辑之前,得先有一个能稳定调用的 ChatModel。Spring AI 默认对接 OpenAI 协议,我们只需要把 base-url 和 api-key 指向兼容服务即可。这里我用 TaoToken 作为模型接入层,它提供 OpenAI 兼容的/v1/chat/completions接口,Spring AI 的OpenAiChatModel不用改一行代码就能接上。
操作路径很直接:打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建一个 API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建时建议按环境分 Key,比如spring-ai-dev、spring-ai-prod,方便后续做用量隔离和吊销。
拿到 Key 之后,接口地址填https://taotoken.net/api,注意这个地址不带任何查询参数。如果你用的是 Spring AI 的 OpenAI starter,配置里base-url写https://taotoken.net/api,Spring AI 会自动拼上/v1/chat/completions。模型名按你实际开通的填,比如gpt-4o-mini或claude-3-5-sonnet这类兼容名称。
注意:Key 不要硬编码进代码或提交到 Git。用环境变量
TAOTOKEN_API_KEY注入,Spring 配置里写${TAOTOKEN_API_KEY}占位。
如果你还没决定用哪个模型,可以先去模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 手动试几轮,确认响应格式和延迟符合预期,再落到代码里。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的参数说明和错误码对照。
3. 可复制配置:依赖、YAML 与记忆 Bean
3.1 Maven 依赖
核心是 JDBC 记忆持久化加定时任务,不需要额外中间件:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-chat-memory-jdbc</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency>3.2 application.yml
这里把模型接入、数据库、记忆 TTL 三块配置集中管理:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/spring_ai_memory?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD} ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 chat: memory: jdbc: initialize-schema: true table-prefix: ai_chat_ time-to-live: 7dtime-to-live: 7d表示登录用户会话记忆 7 天有效,游客场景可以单独配 1h。initialize-schema: true会在启动时自动建表,生产环境建议改成false并用 Flyway 管理。
3.3 智能记忆 Bean
这是整套方案的核心。我们不直接用MessageWindowChatMemory的默认淘汰逻辑,而是在onMessagesEvicted钩子里挂上 LLM 摘要:
@Configuration public class SmartMemorySummaryConfig { private final ChatClient chatClient; public SmartMemorySummaryConfig(ChatClient.Builder builder) { this.chatClient = builder.build(); } @Bean public ChatMemory smartChatMemory(JdbcChatMemoryRepository repository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(5) .onMessagesEvicted(this::autoSummaryConversation) .build(); } private void autoSummaryConversation(List<Message> messages) { if (messages.size() < 15) { return; } String content = messages.stream() .map(Message::getContent) .collect(Collectors.joining("\n")); String summary = chatClient.prompt() .user("精简以下对话,仅保留用户核心需求、业务配置、技术偏好,删除闲聊,200字以内:\n" + content) .call() .getContent(); log.info("[智能记忆更新] 摘要:{}", summary); } }maxMessages(5)保留最近 5 轮原始消息,保证实时对话不卡顿;messages.size() < 15是摘要触发阈值,低于 15 条不调 LLM,避免频繁压缩把成本又拉上去。摘要结果建议单独存一张ai_chat_summary表,和原始消息物理隔离,方便溯源。
3.4 自动遗忘定时任务
TTL 只做逻辑标记,不物理删除,数据表会越积越大。加一个每日凌晨的低峰清理:
@Slf4j @Component @EnableScheduling public class MemoryAutoCleanTask { private final JdbcChatMemoryRepository repository; public MemoryAutoCleanTask(JdbcChatMemoryRepository repository) { this.repository = repository; } @Scheduled(cron = "0 0 2 * * ?") public void cleanExpiredChatMemory() { try { repository.deleteAllExpired(); log.info("[AI记忆运维] 过期会话清理完成"); } catch (Exception e) { log.error("[AI记忆运维] 清理异常", e); } } }3.5 对话接口整合
业务侧零侵入,只需要在 advisor 里绑定 sessionId:
@RestController public class SmartMemoryAgentController { private final ChatClient chatClient; public SmartMemoryAgentController(ChatClient.Builder builder, ChatMemory smartChatMemory) { this.chatClient = builder .defaultAdvisors(MessageChatMemoryAdvisor.builder(smartChatMemory).build()) .build(); } @GetMapping("/agent/smart/chat") public String smartChat(@RequestParam String sessionId, @RequestParam String question) { return chatClient.prompt() .user(question) .advisors(a -> a.param( MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID, sessionId)) .call() .getContent(); } }sessionId是会话隔离的关键,不同用户传不同值,记忆互不串扰。
4. 验证请求与成功结果
配置写完后,按下面四步验证,每一步都有明确的观察点。
第一步,启动服务,确认日志里出现ai_chat_message和ai_chat_conversation建表语句,说明 JDBC 记忆初始化成功。如果报Table already exists,把initialize-schema改成false即可。
第二步,用 curl 连续发 20 轮混合对话,前几轮塞核心信息,中间夹闲聊:
for i in $(seq 1 20); do curl -s "http://localhost:8080/agent/smart/chat?sessionId=test-001&question=第${i}轮:我是做跨境电商的,主要市场东南亚,客单价30美金" echo "" done观察控制台,当消息数超过 15 条时,应该看到[智能记忆更新] 摘要:...日志,摘要里保留了“跨境电商、东南亚、30 美金”这些关键词,闲聊被过滤掉。
第三步,重启服务,用同一个sessionId=test-001提问“我之前说的客单价是多少”,模型应该能答出 30 美金。这一步验证的是摘要记忆跨重启留存。
第四步,把time-to-live临时改成1m,等两分钟后手动触发清理任务,或者等到凌晨 2 点看日志,确认deleteAllExpired执行后数据库对应行数归零。
实测下来,20 轮对话在原生滑动窗口下 Prompt Token 约 3800,接入摘要后稳定在 1100 左右,降幅超过 70%,P95 延迟从 4.2s 回到 900ms 区间。
5. 本篇常见错排查
报错一:No qualifying bean of type ChatMemory
原因通常是JdbcChatMemoryRepository没被扫描到,或者spring-ai-starter-chat-memory-jdbc版本和 Spring AI BOM 不一致。检查pom.xml里是否引入了spring-ai-bom并统一版本号。另外确认@Configuration类在启动类的同级或子包下。
报错二:摘要日志一直不触发
先看messages.size()是否真的到了 15。onMessagesEvicted只在消息被淘汰时回调,如果maxMessages设得比阈值还大,永远不会触发。确保maxMessages(5)小于摘要阈值 15。还有一种情况是ChatMemoryBean 被覆盖了,检查是否有其他地方也定义了ChatMemory。
报错三:deleteAllExpired报 SQL 语法错误
不同数据库的 TTL 字段类型不一样。MySQL 下time_to_live是timestamp,如果手动改过表结构导致类型不匹配就会报错。建议直接用initialize-schema: true生成的表结构,不要手改。生产环境用 Flyway 时,把官方 DDL 拷过去执行。
报错四:模型调用返回 401 或 404
先确认base-url写的是https://taotoken.net/api,不要多加/v1,Spring AI 会自己拼。401 一般是 Key 无效或没带Bearer前缀,检查环境变量TAOTOKEN_API_KEY是否注入成功。404 多半是模型名写错,去模型对话页确认可用模型列表。如果还是不通,直接看接入文档里的 curl 示例对照排查。
报错五:摘要内容把关键信息也删了
这是 Prompt 写得太粗。把摘要指令改得更具体,比如“必须保留:用户身份、业务领域、数值参数、技术栈偏好;可以删除:问候、确认、重复提问”。另外把摘要阈值从 15 调到 20,给 LLM 更多上下文判断。
6. 下一步:把记忆能力接到生产链路
到这里,短期窗口加 LLM 摘要加自动遗忘的三层骨架已经跑通了。你可以先把这套配置落到测试环境,用真实业务对话跑一周,观察摘要质量和 Token 曲线。如果发现摘要调用本身成本偏高,可以把摘要模型换成更便宜的型号,主对话用强模型,两者分开配置。
需要长期做编码或 Agent 场景的话,建议直接上 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,它针对高频代码补全和长上下文做了额度优化,比按量计费更可控。接入过程中如果遇到 Key 权限或模型路由问题,先查接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,再对照 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 确认 Key 状态。整套方案不依赖任何非标准组件,换模型只需改application.yml里的 model 字段,记忆逻辑完全复用。