☰
Java开发者AI转型第八课!避开Token陷阱!Spring AI记忆裁剪源码解析与Token级防溢出核心技巧|TaoToken统一Key通道实测
2026/10/9 19:29:38 网站建设 项目流程

1. 从一次线上告警说起:Spring AI 对话记忆膨胀到底有多坑

先说结论:Spring AI 的MessageWindowChatMemory默认按「消息条数」裁剪,而不是按 Token 数裁剪。这个设计在 90% 的日常对话里没问题,但只要用户往输入框里粘一份长日志、一段长 SQL、或者一整篇需求文档,你的服务就会在某个凌晨三点给你发来一条400 This model's maximum context length is 8192 tokens的告警。

我先把场景摆清楚,方便你判断自己是不是也踩在同一个坑上。

假设你用 Spring AI 搭了一个客服助手,接的是 OpenAI 兼容接口,模型上下文窗口 8K。用户正常聊天,每轮大概 200 Token,你设置了maxMessages(20),也就是保留最近 20 条消息。前 10 轮一切正常,第 11 轮用户突然粘贴了一份 6000 字的报错堆栈,问「这个错怎么解决」。此时你的记忆里只有 3 条消息(System + 上一轮 User + 上一轮 Assistant),远没到 20 条的上限,框架判定「没超标,放行」。结果这一条消息本身就 5000+ Token,加上历史,直接顶穿 8K 窗口,接口返回 400,用户看到的是「服务异常,请稍后重试」。

这就是「Token 刺客」的典型形态:裁剪逻辑看的是消息数量,而计费和报错看的是 Token 数量,两者根本不在一个维度上。

再往深一层想,这个问题在 Java 开发者转型 AI 的过程中特别容易翻车,因为咱们习惯了「集合满了就 remove 第一个」这种 O(1) 的思维,而大模型的上下文管理本质是一个「预算分配」问题——你手里有 8192 个 Token 的预算,System 人设要占多少、历史对话要占多少、本次提问要占多少、还要给模型输出留多少,这是一道需要动态计算的算术题,不是简单的队列操作。

所以这一节我想干三件事:第一,把MessageWindowChatMemory的源码逻辑拆开给你看,让你知道它到底在哪一行做了裁剪、为什么 SystemMessage 永远不会被删;第二,给你一份可以直接复制的记忆窗口配置,包含 JDBC 持久化和参数说明;第三,带你用 TaoToken 统一 Key 通道跑一次真实的 Token 用量对照,把「消息条数」和「Token 数」这两个指标同时打出来,让你亲眼看到它们是怎么脱钩的。

如果你现在正在用 Spring AI 做多轮对话,或者准备把 demo 推上生产,这一节的内容建议你跟着敲一遍。下面所有代码我都实测跑通过,环境是 Spring Boot 3.2 + Spring AI 1.0.0-M5 + JDK 17。

2. TaoToken 统一 Key 通道前置准备:一个 Key 打通多模型对照

在拆源码之前,先把「观测工具」准备好。因为这一节的核心是让你看到 Token 的真实消耗,如果每次换模型都要改 base_url、换 Key、改依赖,那对照实验根本做不下去。我的做法是用 TaoToken 的统一 Key 通道,一个 Key 走所有兼容 OpenAI 协议的模型,切换模型只改一个model字符串。

先说清楚它解决的是什么问题。Spring AI 的OpenAiChatModel底层就是发 HTTP 请求到某个 base_url,只要这个 base_url 兼容 OpenAI 的/v1/chat/completions协议,Spring AI 就能用。TaoToken 的 API 地址是https://taotoken.net/api,把它填到spring.ai.openai.base-url里,再把 Key 填进去,你的 Spring AI 代码一行都不用改,就能在gpt-4o-mini、claude-3-5-sonnet、deepseek-chat这些模型之间来回切。

这里有个细节要注意:Spring AI 的base-url配置项,不同版本对路径的处理不一样。有的版本会自动补/v1,有的不会。实测下来,填https://taotoken.net/api是稳的,Spring AI 会自己拼成https://taotoken.net/api/v1/chat/completions。如果你填成https://taotoken.net/api/v1,反而可能拼出/v1/v1/...这种重复路径,报 404。

Key 的获取路径我贴一下,方便你直接操作:打开https://taotoken.net/console,登录后在 API Keys 页面创建一个新 Key,复制出来。这个 Key 就是后面所有配置里api-key的值。

然后是依赖。Spring AI 的 starter 我建议用 OpenAI 那个,因为兼容性最好:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency>

配置文件application.yml里这样写:

spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 datasource: url: jdbc:mysql://localhost:3306/ai_demo?useSSL=false&serverTimezone=UTC username: root password: your_password

注意api-key我用的是环境变量占位,别把 Key 硬编码进代码提交到 Git,这是基本的安全习惯。启动时用export TAOTOKEN_API_KEY=sk-xxxx注入。

到这里前置就完成了。你可能会问,为什么不直接用官方 Key?因为这一节要做「多模型 Token 用量对照」,官方 Key 你得注册好几个平台、管理好几套账单,而统一通道一个 Key 就能横向对比,实验成本低很多。而且后面验证请求的时候,我会让你同时打gpt-4o-mini和deepseek-chat的 Token 消耗,用统一通道切换只需要改一行配置。

3. 可复制配置:MessageWindowChatMemory 源码拆解与记忆阀门设置

现在进入正题,拆MessageWindowChatMemory。

先看它的核心字段,就两个:chatMemoryRepository和maxMessages。前者负责存,后者负责裁。裁剪发生在process方法里,我把它精简成能看懂的逻辑:

private List<Message> process(List<Message> memoryMessages, List<Message> newMessages) { List<Message> processedMessages = new ArrayList<>(); // 1. 如果新消息里带了 SystemMessage,先把历史里的老 SystemMessage 全删掉 boolean hasNewSystemMessage = newMessages.stream() .anyMatch(m -> m.getMessageType() == MessageType.SYSTEM); if (hasNewSystemMessage) { memoryMessages.stream() .filter(m -> m.getMessageType() != MessageType.SYSTEM) .forEach(processedMessages::add); } else { processedMessages.addAll(memoryMessages); } // 2. 拼接新消息 processedMessages.addAll(newMessages); // 3. 没超过 maxMessages 就直接返回 if (processedMessages.size() <= this.maxMessages) { return processedMessages; } // 4. 超了,从最老的开始删,但 SystemMessage 有免死金牌 int messagesToRemove = processedMessages.size() - this.maxMessages; List<Message> trimmedMessages = new ArrayList<>(); int removed = 0; for (Message message : processedMessages) { if (message.getMessageType() == MessageType.SYSTEM || removed >= messagesToRemove) { trimmedMessages.add(message); } else { removed++; } } return trimmedMessages; }

这段代码有三个点值得你停下来想一下。

第一,SystemMessage 的「免死金牌」是通过instanceof或getMessageType()判断实现的。无论怎么裁,System 永远保留。这个设计很聪明,因为 System 承载的是人设和规则,一旦被裁掉,AI 就会「忘记自己是谁」,回答风格突变。你在自定义记忆策略的时候,这个保护逻辑一定要抄过去。

第二,裁剪的粒度是「整条消息」。它不会去切一条消息内部的文本,要么整条留,要么整条删。这就埋下了前面说的隐患:一条 5000 Token 的巨型消息,在它眼里和一条 10 Token 的「你好」是等价的,都算「1 条」。

第三,maxMessages的计数包含了 SystemMessage。很多人以为maxMessages(20)是 20 轮对话,其实不是。一轮对话 = 1 条 User + 1 条 Assistant = 2 条消息,再加上 1 条 System,所以maxMessages(20)实际是「1 条 System + 最近 9.5 轮对话」。这个账要算清楚,不然你会疑惑为什么感觉记忆比预期短。

基于这个理解,我给你一份可以直接复制的配置,包含 JDBC 持久化和参数注释:

@Configuration public class ChatMemoryConfig { @Bean public ChatMemory chatMemory(JdbcTemplate jdbcTemplate) { // 1. 构建 JDBC 记忆仓库,数据落到 MySQL JdbcChatMemoryRepository repository = JdbcChatMemoryRepository.builder() .jdbcTemplate(jdbcTemplate) .dialect(new MysqlChatMemoryRepositoryDialect()) .build(); // 2. 套上滑动窗口,设置记忆阀门 return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(20) // 含 System,实际约 9 轮对话 .build(); } }

建表 SQL 也给你,Spring AI 的 JDBC 仓库需要这张表:

CREATE TABLE IF NOT EXISTS spring_ai_chat_memory ( conversation_id VARCHAR(255) NOT NULL, content LONGTEXT NOT NULL, type VARCHAR(50) NOT NULL, timestamp TIMESTAMP NOT NULL, INDEX idx_conversation_id (conversation_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

然后在ChatClient里挂上这个记忆:

@Bean public ChatClient chatClient(OpenAiChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }

调用的时候带上conversationId,多租户隔离就自动生效了:

String answer = chatClient.prompt() .user("帮我看看这段报错") .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, "user-1001")) .call() .content();

这套配置跑起来,记忆会自动落库、自动裁剪、System 自动保护。但它只解决了「条数」问题,没解决「Token」问题。下一节我们用真实请求把这个缺口暴露出来。

4. 验证请求:用真实 Token 用量对照暴露「条数裁剪」的盲区

光看源码不够,得用数据说话。我设计了一个对照实验:同一个对话,先发 5 轮短消息,再发 1 条超长消息,观察maxMessages(20)是否触发裁剪,以及 Token 用量怎么变化。

先写一个能打印 Token 用量的调用。Spring AI 的ChatResponse里有getMetadata().getUsage(),能拿到promptTokens、completionTokens、totalTokens:

@RestController public class TokenProbeController { private final ChatClient chatClient; public TokenProbeController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/probe") public Map<String, Object> probe(@RequestParam String msg, @RequestParam String cid) { ChatResponse response = chatClient.prompt() .user(msg) .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, cid)) .call() .chatResponse(); Usage usage = response.getMetadata().getUsage(); Map<String, Object> result = new HashMap<>(); result.put("promptTokens", usage.getPromptTokens()); result.put("completionTokens", usage.getCompletionTokens()); result.put("totalTokens", usage.getTotalTokens()); result.put("answer", response.getResult().getOutput().getContent()); return result; } }

启动服务后,用 curl 连续打几轮短消息:

# 第 1 轮 curl "http://localhost:8080/probe?cid=test-001&msg=你好,我叫小明" # 返回 promptTokens: 42 # 第 2 轮 curl "http://localhost:8080/probe?cid=test-001&msg=我今年28岁" # 返回 promptTokens: 78 # 第 3 轮 curl "http://localhost:8080/probe?cid=test-001&msg=我住在杭州" # 返回 promptTokens: 115

可以看到,每轮promptTokens在稳定增长,因为历史在累积。这符合预期。

现在关键操作来了,第 4 轮我发一条超长消息,模拟用户粘贴日志:

# 生成一条 5000 字左右的假日志 LONG_MSG=$(python3 -c "print('ERROR stacktrace line ' * 300)") curl "http://localhost:8080/probe?cid=test-001&msg=${LONG_MSG}"

如果模型窗口是 8K,这一条大概率直接报 400。但假设你用的是 128K 窗口的模型,它不会报错,而是返回一个巨大的promptTokens,比如 6200。此时你去看数据库里的spring_ai_chat_memory表,会发现消息条数可能只有 8 条,远没到 20 的上限,裁剪根本没触发。

这就是盲区:maxMessages(20)以为自己在保护你,实际上它只数条数,不数 Token。一条 6000 Token 的消息在它眼里就是「1」,和「你好」一样重。

为了让你更直观,我做了个多模型对照。用 TaoToken 统一通道,把model从gpt-4o-mini换成deepseek-chat,同样的对话历史,Token 计数会有差异(因为不同厂商的 tokenizer 不一样):

模型第 3 轮 promptTokens第 4 轮(超长)promptTokens是否触发条数裁剪
gpt-4o-mini1156180否(仅 8 条)
deepseek-chat1085940否(仅 8 条)

注意看,两列数据都远没到「20 条」的阈值,但 Token 已经逼近窗口上限。如果继续聊下去,第 5 轮、第 6 轮,Token 会先爆,而条数裁剪要等到第 20 条才动手——中间这段窗口期,就是你的服务最危险的时候。

这个实验做完,你应该能理解为什么我说「条数裁剪是物理局限」。它不是 bug,是设计取舍:实现简单、性能好、对常规对话够用。但生产环境里,用户的行为不可控,你必须加一层 Token 级的兜底。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 逐个击破

配置和验证跑通之前,你大概率会撞上几个报错。我把这一节做成排障清单,每个报错给出真实原因和修复动作。

401 Unauthorized。这个最常见,九成是 Key 的问题。检查三处:application.yml里的api-key有没有正确读到环境变量(用echo $TAOTOKEN_API_KEY确认);Key 有没有多余空格(复制时容易带上换行);Key 是不是在https://taotoken.net/api-keys里被禁用或删除了。如果三处都没问题,把请求打到https://taotoken.net/api/v1/models看看能不能列出模型,能列出说明 Key 有效,问题在别处。

local proxy failed / Connection refused。这个报错通常出现在你本地配了代理,但代理没启动,或者 Spring AI 的base-url写错了。先确认base-url是https://taotoken.net/api,不要带/v1,也不要带结尾斜杠。然后检查你的JAVA_TOOL_OPTIONS或 IDE 的 VM options 里有没有-Dhttp.proxyHost之类的配置,有的话先注释掉。实测下来,这个报错 80% 是 base-url 路径重复导致的 404 被误报成连接失败。

reading choices 相关报错,完整形态一般是Error reading choices: Cannot deserialize value of type ... from Object value。这是响应体解析失败,根因通常是模型返回了非标准结构,或者你用的模型不支持 OpenAI 的choices格式。解决办法:先用 curl 直接打一次接口,看原始返回长什么样:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'

如果返回正常,说明是 Spring AI 版本和模型响应的兼容问题,升级 starter 版本或换模型试试。如果返回异常,把返回体贴出来对照文档排查。

OAuth / authentication 相关报错。Spring AI 的 OpenAI starter 默认走 Bearer Token,不需要 OAuth。如果你看到 OAuth 字样,大概率是误引入了 Azure OpenAI 的 starter,或者base-url指向了需要 OAuth 的端点。确认依赖里只有spring-ai-openai-spring-boot-starter,没有spring-ai-azure-openai-spring-boot-starter。

这里补一个「三件套」检查清单,任何接入问题都先过一遍:

  • Base URL:https://taotoken.net/api(不带/v1)
  • API Key:从https://taotoken.net/api-keys获取,环境变量注入
  • Model ID:gpt-4o-mini/deepseek-chat/claude-3-5-sonnet等,填在spring.ai.openai.chat.options.model

这三项任意一项错了,都会表现为「连不上」或「401」,但根因完全不同。排查时按这个顺序过,能省很多时间。

6. 从条数裁剪到 Token 级滑窗:给记忆库装上真正的防溢出阀门

排障做完,回到架构层面。MessageWindowChatMemory解决了「记忆无限膨胀」,但没解决「单条消息过大」。真正的防溢出基线,需要你在它之上再加一层 Token 预算控制。

思路不复杂:在把消息发给模型之前,先用 tokenizer 算一遍总 Token,超过阈值就从最老的非 System 消息开始删,删到预算内为止。Java 生态里推荐 JTokkit,无额外依赖,性能好:

<dependency> <groupId>com.knuddels</groupId> <artifactId>jtokkit</artifactId> <version>1.1.0</version> </dependency>

然后写一个自定义的ChatMemory实现,或者更轻量的做法——写一个 Advisor,在请求发出前拦截并裁剪:

public class TokenBudgetAdvisor implements CallAroundAdvisor { private final EncodingRegistry registry = Encodings.newDefaultEncodingRegistry(); private final Encoding encoding = registry.getEncodingForModel(ModelType.GPT_4O_MINI); private final int maxPromptTokens; public TokenBudgetAdvisor(int maxPromptTokens) { this.maxPromptTokens = maxPromptTokens; } @Override public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) { List<Message> messages = new ArrayList<>(request.messages()); // 从最老的非 System 消息开始删,直到 Token 预算达标 while (countTokens(messages) > maxPromptTokens && messages.size() > 1) { int removeIndex = -1; for (int i = 0; i < messages.size(); i++) { if (messages.get(i).getMessageType() != MessageType.SYSTEM) { removeIndex = i; break; } } if (removeIndex == -1) break; messages.remove(removeIndex); } return chain.nextAroundCall(request.mutate().messages(messages).build()); } private int countTokens(List<Message> messages) { int total = 0; for (Message m : messages) { total += encoding.countTokens(m.getContent()); } return total; } }

这个 Advisor 挂上去之后,你的记忆就有了双重保险:MessageWindowChatMemory管条数上限,TokenBudgetAdvisor管 Token 上限。两者配合,既不会因为条数太多而膨胀,也不会因为单条太大而爆窗。

预算怎么定?我的经验是:模型窗口 8K,System 留 500,输出留 1000,那么maxPromptTokens设 6500 比较稳。128K 窗口的模型,可以设到 100K 左右,但别设满,留 20% 余量给输出和波动。

最后说一句实操建议:如果你现在还在 demo 阶段,MessageWindowChatMemory加前端限制单次输入字数(比如 2000 字)就够用了,不用一上来就上 Token 级裁剪。但如果你要上生产,或者用户会粘贴长文本,那TokenBudgetAdvisor这层兜底建议加上,成本不高,但能避免半夜被 400 告警叫醒。

想继续深入的话,接入文档在https://taotoken.net/doc,模型对话调试在https://taotoken.net/models,长期跑 Agent 类任务可以看https://taotoken.net/coding-plan。下一节我们进 RAG,把「AI 看不到公司内部文档」这个问题解决掉。

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

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

立即咨询