單輪對話正常,第二輪開始必現
400 Bad Request。根因是MessageChatMemoryAdvisor把歷史消息插到messages最前面,.system(...)設的 SystemMessage 從第 0 條被擠到第 1 條。OpenAI 要求 system 必須是第一條,否則直接 400。加一個重排 advisor 把它放回最前就行。
Issue & Env(案發現場與環境)
- 業務場景:基於
ChatClient做多輪對話,開了MessageChatMemoryAdvisor,用JdbcChatMemoryRepository持久化記憶,並顯式傳入conversationId。 - 現象:第一輪問答完全正常,從第二輪開始,每次調用都返回 400。
- 環境:
spring-ai-bom1.1.2,Spring Boot 3.5.4,OpenAI 官方 API,依賴spring-ai-starter-model-openai。
單輪能過、多輪必掛,這個分界線本身就指向「歷史消息」這一環,而不是模型或 key 的問題。
StackTrace(堆疊日誌)
底層 HTTP 客戶端把 400 包成了 WebClientResponseException。報錯原文:
org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest: 400 Bad Request from POST https://api.openai.com/v1/chat/completions
社區案例裡還能看到 Reactor 的組裝信息。它本身不是根因,只是把「請求是在哪條響應式鏈上發出的」補出來:
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: Assembly trace from producer [reactor.core.publisher.MonoIgnoreThen] : reactor.core.publisher.Mono.then(Mono.java:4879)
更完整的一段,從 advisor 鏈一路回到業務調用:
org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest: 400 Bad Request from POST https://api.openai.com/v1/chat/completions
at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:315)
at org.springframework.web.reactive.function.client.DefaultClientResponseBuilder.lambda$createException$1(DefaultClientResponseBuilder.java:230)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:132)
at org.springframework.ai.openai.api.OpenAiApi.lambda$chatCompletionStream$4(OpenAiApi.java:302)
at org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor.adviseStream(MessageChatMemoryAdvisor.java:210)
at org.springframework.ai.chat.client.DefaultChatClient$DefaultChatClientRequestSpec$InternalChatClientRequest.call(DefaultChatClient.java:686)
at com.example.demo.chat.ChatService.ask(ChatService.java:41)
封面報錯是 400,不是鑑權也不是限流,說明請求發出去了,只是格式不被接受。
Reproduce(復現程式碼)
先建一個帶記憶的 ChatClient:
ChatMemory memory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new JdbcChatMemoryRepository(jdbcTemplate))
.maxMessages(20)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是一个简洁的助手。")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
.build();
再用同一個 conversationId 連問兩次:
chatClient.prompt()
.user("你好")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "c-1"))
.call().content();
chatClient.prompt()
.user("再说一句")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "c-1"))
.call().content();
第一次正常返回,第二次直接拋 400。把第二次的 conversationId 換成新的,又會恢復正常。
Root Cause(根因分析)
MessageChatMemoryAdvisor 在請求發出前,會把該會話的歷史消息取出來,prepend 到當前 messages 列表的前面。
.system(...) 生成的 SystemMessage 原本在 index 0。歷史消息插進來之後,它被擠到 index 1。
OpenAI 的 Chat Completions 規範要求:只要帶了 system 消息,它就必須是 messages 陣列的第一條。第二輪開始 system 不在首位,服務端直接判 400。
定位方法很直接。開 DEBUG 日誌,或者在 org.springframework.ai.openai.api.OpenAiApi#chatCompletionStream 裡打斷點,看 messages 的順序:
- 第一輪:index 0 是
SYSTEM,後面是用戶消息。 - 第二輪:index 0 變成歷史裡的
USER,SYSTEM掉到 index 1。
順序一對比,問題就清楚了。這也解釋了一個常見誤判:很多人以為是多輪上下文超長,其實長度沒問題,是排序不對。
為什麼第一輪正常
第一輪時,會話裡還沒有歷史。MessageChatMemoryAdvisor 取出來的列表是空的,prepend 等於沒做事。SystemMessage 穩穩待在 index 0,請求自然合法。
只有歷史非空時,插入才會發生,順序才會被打亂。這就是「單輪正常、多輪必掛」的分界線。任何一次會話,從第二輪起都會踩到它。
order 與責任劃分
記憶 advisor 會在 prompt 送模型前動手。它把歷史插到最前,是它的職責,本身沒錯。錯在 system 的位置沒人兜底。
所以修復不該去改記憶 advisor 的行為,而是在鏈路末端補一層重排。職責分開,改動最小。
截至撰寫時未經核實:不同 Spring AI 小版本對這條約束的校驗位置略有差異,但觸發 400 的機制一致。
The Fix(解決方案)
寫一個 advisor,同時實現 CallAdvisor 和 StreamAdvisor。在 adviseCall / adviseStream 裡拿到請求後,把 SystemMessage 重新排到最前。
order 取一個很大的值,例如 Integer.MAX_VALUE - 1000,讓它最後執行。這樣即使鏈路上有別的 advisor 動過順序,重排也還是最後一手。
import java.util.ArrayList;
import java.util.List;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import reactor.core.publisher.Flux;
public class SystemMessageReorderAdvisor implements CallAdvisor, StreamAdvisor {
@Override
public String getName() {
return "SystemMessageReorderAdvisor";
}
@Override
public int getOrder() {
return Integer.MAX_VALUE - 1000;
}
private ChatClientRequest reorder(ChatClientRequest request) {
List<Message> messages = request.prompt().getInstructions();
List<Message> systems = messages.stream()
.filter(m -> m.getMessageType() == MessageType.SYSTEM)
.toList();
if (systems.isEmpty()) {
return request;
}
List<Message> ordered = new ArrayList<>(systems);
messages.stream()
.filter(m -> m.getMessageType() != MessageType.SYSTEM)
.forEach(ordered::add);
return request.mutate()
.prompt(request.prompt().mutate().messages(ordered).build())
.build();
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
return chain.nextCall(reorder(request));
}
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
return chain.nextStream(reorder(request));
}
}
註冊時把它放在 advisor 列表裡:
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是一个简洁的助手。")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(memory).build(),
new SystemMessageReorderAdvisor())
.build();
兩個細節值得注意。
第一,adviseCall 和 adviseStream 都要實現。只改同步鏈路,流式對話照樣 400。
第二,別用「把 system 內容也塞進歷史」的思路繞。system 歸 system,歷史歸歷史。重排是最小改動,也最不容易踩新的坑。
改完再跑一遍那個連問兩次的復現用例,第二輪應該正常返回,messages 裡 SYSTEM 始終待在 index 0。
如果重排後還是 400,多半是 advisor 沒掛上,或者順序被別的設定覆蓋了。先確認它真的進了 defaultAdvisors,再確認沒有別的重排邏輯排在它後面執行。
References(參考來源)
- Chat Memory — Spring AI 官方文檔spring-docs
- Advisors API — Spring AI 官方文檔spring-docs
- SpringAI 多輪對話報錯 400 Bad Request(掘金)juejin
