多轮问答突然 400,报
context_length_exceeded,重启进程又恢复正常。看起来像玄学,其实是MessageWindowChatMemory按「条数」截断,默认 20 条,根本不看 token。单条消息上千 token 时,条数没超、token 早爆。RAG 的 topK 和 chunk size 是主因。
Issue & Env(案发现场与环境)
- 业务场景:多轮问答,挂了
MessageChatMemoryAdvisor管理历史,同时用 RAG 把检索到的文档拼进 prompt。 - 现象:问着问着突然 400,重启进程后又能撑一阵,然后再次 400。
- 环境:Spring AI 1.1.x,Spring Boot 3.x,OpenAI 官方 API,
gpt-4o-mini这类 16K 上下文的模型。
「重启就好」这个特征很关键。它说明状态在内存里,每次请求都在往上叠,而不是某段代码写错了。
StackTrace(堆栈日志)
OpenAI 返回的错误体,原样保留:
Error code: 400 - {'error': {'message': "This model's maximum context length is 16385 tokens. However, your messages resulted in 21000 tokens. Please reduce the length of the messages.", 'type': 'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}
Spring AI 侧把它包成不可重试异常。4xx 默认不重试,直接往外抛:
org.springframework.ai.retry.NonTransientAiException: 400 - {"error":{"message":"This model's maximum context length is 16385 tokens. However, your messages resulted in 21000 tokens. Please reduce the length of the messages.","type":"invalid_request_error","param":"messages","code":"context_length_exceeded"}}
at org.springframework.ai.retry.RetryUtils.handleResponseError(RetryUtils.java:163)
at org.springframework.ai.chat.model.ChatModel.call(ChatModel.java:204)
at org.springframework.ai.chat.client.DefaultChatClient$DefaultChatClientRequestSpec$InternalChatClientRequest.call(DefaultChatClient.java:686)
at org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor.adviseCall(QuestionAnswerAdvisor.java:198)
at com.example.demo.chat.ChatService.ask(ChatService.java:41)
at com.example.demo.chat.ChatController.ask(ChatController.java:26)
param 是 messages,code 是 context_length_exceeded,说明是消息体本身太大,不是输出被截断。
Reproduce(复现代码)
把记忆和 RAG 一起挂上:
ChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(memory).build(),
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(10).build())
.build())
.build();
QuestionAnswerAdvisor 会把 topK 个 chunk 全拼进 prompt。把 topK 调到 10 以上,再叠几轮长回答,很容易撞上 16385 的上限。用同一个会话连续提问,跑到第几轮就会挂。
Root Cause(根因分析)
关键点:MessageWindowChatMemory 的 maxMessages 是按「消息条数」截断的,默认 20 条。它不关心每条消息有多少 token。
于是出现这种局面:条数稳稳压在 20 以内,token 却早就超了。常见来源有三个。
- 单条消息本身就很大:用户把整篇文档贴进对话框。
- RAG 把所有检索结果拼进 prompt:topK 个 chunk,每个几百到上千 token。
- system prompt 里塞了长规则:每次请求都要带上。
RAG 是主因。topK 与 chunk size 一放大,token 增长接近乘法级。检索本身没错,但拼进 prompt 的量没人管。
估算思路如下,不追求精确:
prompt token ≈ system 长度 + 历史条数 × 单条均值 + topK × chunk 大小
按 20 条历史、每条 800 token、topK 10、chunk 500 token 算,光历史加检索就是 16000 + 5000,早已越过 16385。
为什么重启能恢复?因为 MessageWindowChatMemory 默认是内存态,重启清空记忆,历史归零,自然又能撑一阵。这也反过来证明问题出在历史累积,不在代码本身。如果记忆落在 JDBC 或 Redis 这类持久化实现上,重启就不管用了。
还有一类更容易忽略的来源:工具调用与多模态。工具返回的长结果会被塞回 prompt,当成下一轮输入。图片按 token 折算也不便宜。它们不走 RAG,但同样计入上下文。排查时别只盯着 RAG。
The Fix(解决方案)
按顺序做,从便宜的先来。
调小 maxMessages 和 topK
ChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(8)
.build();
SearchRequest request = SearchRequest.builder()
.topK(4)
.similarityThreshold(0.7)
.build();
maxMessages 越小,历史越短;topK 越小,拼进 prompt 的 chunk 越少。similarityThreshold 能滤掉低相关 chunk,进一步省 token。
控制 chunk size
切分文档时把 chunk 调小,别让单个 chunk 就上千 token。chunk 太大,检索到的内容粗糙,还白占上下文。
长文档先摘要再进 prompt
把「贴整篇文档」换成先摘要,再把摘要塞进 prompt。原始长文交给检索,不直接进对话历史。
需要精确控制时,按 token 预算裁剪
maxMessages 只管条数。想精确控 token,就得自己算。用 jtokkit 这类 tokenizer 估算每条消息的 token 数,按预算从新到旧累计,装不下就停。
// 伪代码:按 token 预算裁剪历史,而不是按条数
int budget = 6000;
int used = 0;
List<Message> kept = new ArrayList<>();
for (Message m : reversed(history)) {
int t = estimateTokens(m.getText());
if (used + t > budget) {
break;
}
used += t;
kept.add(m);
}
Collections.reverse(kept);
每次请求前都算一遍 token 预算,比只设一个 maxMessages 稳得多。预算要留出输出空间,别把上限全给输入。
改完要验证
别只看有没有报错。开 DEBUG 日志,或临时打印请求体,数一下每轮的 prompt token。能看到实际用量,才谈得上控制。看不到,就还是在撞运气。
判断标准也简单:调完观察连续对话能撑多少轮。撑不过十轮,说明预算还是给大了。最好在日志里打个点,记录每轮 token 曲线,方便定位是哪一类消息把它推高的。
References(参考来源)
- Error codes(400 context_length_exceeded)— OpenAI 官方文档openai-docs
- Chat Memory — Spring AI 官方文档spring-docs
