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

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法