接 Claude、DeepSeek、Gemini 这类带 thinking 的模型,再挂上工具调用时,下一轮请求必须把上一轮的思考块、签名或加密内容按原顺序原样送回去。官方博客 Spring AI 2.1 Message Parts: Reasoning That Survives the Round Trip 写得很直:丢掉、重排或改写这些片段,结果从质量下滑到 HTTP 400 都有。旧的 AssistantMessage 只有文本,再加旁边的 toolCalls 列表,既保不住顺序,也放不下 Anthropic signature、Gemini thought_signature、OpenAI encrypted_content 这类不透明载荷。旧的 JdbcChatMemoryRepository 只存文本和消息类型,进程一重启,推理轨迹就断了。

Spring AI 2.1.0-RC1 把消息改成有序的 MessagePart 列表。三条规则来自同一篇官方博文:保持顺序;用 OpaquePayload 原样回放;持久化 parts,而不是只存文本。

有序 parts:签名挂在 ReasoningPart 上

环境:JDK 21.0.12.1、Spring Boot 4.1.1、spring-ai-bom 2.1.0-RC1(spring-ai-model 解析为 2.1.0-RC1)。不调厂商接口,手工拼一条工具轮助手消息:

OpaquePayload signature = new OpaquePayload(
    "anthropic", "signature",
    "EqQBCgIYAhIM-demo-signature-do-not-edit");
ReasoningPart reasoning = new ReasoningPart(
    "The user wants the weather in Paris. I should call get_weather.",
    null, signature, Map.of());
TextPart text = TextPart.of("Let me check that for you.");
ToolCallPart toolCallPart = ToolCallPart.of(
    new AssistantMessage.ToolCall(
        "call_1", "function", "get_weather",
        "{\"location\":\"Paris\"}"));

AssistantMessage assistant = AssistantMessage.builder()
    .part(reasoning)
    .part(text)
    .part(toolCallPart)
    .build();

跑起来后,getParts() 的顺序是 ReasoningPart → TextPart → ToolCallPart,签名只活在第一个 part 的 OpaquePayload 里:

parts[0]=ReasoningPart payload=OpaquePayload(provider=anthropic, kind=signature, data=EqQBCgIYAhIM-demo-signature-do-not-edit)
parts[1]=TextPart payload=null
parts[2]=ToolCallPart payload=null
ASSERT orderOk=true
ASSERT opaqueOnParts=true

旧视图还在,但是派生的:getText() 只拼文本,getToolCalls() 只抽工具调用,getReasoning() 只抽思考块。它们各自能读到内容,却不再是「消息本身」——顺序和不透明数据以 getParts() 为准。

getText()=Let me check that for you.
getToolCalls().size()=1
getToolCalls()[0].name=get_weather
getReasoning().size()=1
getReasoning()[0].payload.kind=signature
getReasoning()[0].payload.data=EqQBCgIYAhIM-demo-signature-do-not-edit

MessagePart 是 sealed 接口,常见实现包括 TextPart、ReasoningPart、ToolCallPart、ToolResultPart、MediaPart、UnknownPart。OpaquePayload(provider, kind, data) 不解释内容:产出它的 ChatModel 原样回放,别的模型跳过。官方说明里,不认识的块会落成 UnknownPart,同样按位回放,避免静默丢弃。

Session JDBC:存的是 parts JSON

旧记忆实现只写文本。2.1 Session 的 JDBC 仓库把消息体编成带 parts 数组的 JSON。同一条助手消息写入 H2 后再读回:

loaded.size=1
loaded[0].type=ASSISTANT
loaded.parts[0]=ReasoningPart payload=OpaquePayload(provider=anthropic, kind=signature, data=EqQBCgIYAhIM-demo-signature-do-not-edit)
loaded.parts[1]=TextPart payload=null
loaded.parts[2]=ToolCallPart payload=null
ASSERT sessionRoundtripOrder=true
ASSERT sessionRoundtripOpaque=true

AI_SESSION_EVENT.payload 里能直接看到 "type":"reasoning" 和 signature:

{"parts":[{"type":"reasoning","text":"The user wants the weather in Paris. I should call get_weather.","summary":null,"payload":{"provider":"anthropic","kind":"signature","data":"EqQBCgIYAhIM-demo-signature-do-not-edit"},"attributes":{}},{"type":"text","text":"Let me check that f
ASSERT payloadJsonHasReasoningType=true
ASSERT payloadJsonHasSignature=true

跨重启要带回思考签名,靠的是这份 parts JSON,而不是再从 getText() 拼回去。Session 还有压缩窗口与工具轮边界的能力,站内已写过 Spring AI 2.1.0-RC1:压缩窗口不拆工具轮,这里只关心 parts 能否落库回读。

和 Responses、厂商回放的关系

Spring AI 2.1.0-M1:Responses API 接入与两项限制 里提过早期 MessagePart 痕迹;2.1.0-RC1 把抽象落到全模型消息上,并让 Session 持久化。厂商侧 HTTP 400 / 质量回退的具体触发条件,以官方博文为准:Claude 要回放 thinking 与 signature,Gemini 要 thought_signature,DeepSeek 要 reasoning_content。本文未打真实厂商请求,没有自造 400 日志;本地断言覆盖的是 parts 构造顺序、OpaquePayload 挂载,以及 Session JDBC 往返后 reasoning / signature 仍在。

落地时:下一轮请求走 getParts()(或等价地让 ChatModel / Session 带着完整消息走),不要只拼 getText() + getToolCalls();持久化选会写 parts 的 Session JDBC,而不是旧的文本记忆仓库。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。