StackTraces
Spring AI已驗證

Spring AI 報 400 Bad Request:SystemMessage 錯位

@debug_master13 天前更新閱讀 6 min00

單輪對話正常,第二輪開始必現 400 Bad Request。根因是 MessageChatMemoryAdvisor 把歷史消息插到 messages 最前面,.system(...) 設的 SystemMessage 從第 0 條被擠到第 1 條。OpenAI 要求 system 必須是第一條,否則直接 400。加一個重排 advisor 把它放回最前就行。

Issue & Env(案發現場與環境)

  • 業務場景:基於 ChatClient 做多輪對話,開了 MessageChatMemoryAdvisor,用 JdbcChatMemoryRepository 持久化記憶,並顯式傳入 conversationId。
  • 現象:第一輪問答完全正常,從第二輪開始,每次調用都返回 400。
  • 環境:spring-ai-bom 1.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(參考來源)

最後更新於 2026-09-28

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法