单轮对话正常,第二轮开始必现
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
