多輪問答突然 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
