Spring AI 2.1 消息部件:让推理在往返交互中得以保留
Spring AI: 消息部件文档 | 升级说明 | OpenAI Responses 文档 | Session 文档
推理模型不只是先思考再作答。它们会把一部分思考过程交还给你,并期望在下一次请求中原样收回。Claude 会对其思考块签名,Gemini 会把思考签名附加到函数调用上,OpenAI 的 Responses API 会返回加密的推理项。如果你丢弃、重排或编辑这些内容,结果轻则收到 HTTP 400,重则模型会悄然丢失先前的推理并从头开始。
在此之前,Spring AI 的消息只是一段文本,旁边附带工具调用和媒体列表。这些数据无处安放,更不用说它们的顺序了。Spring AI 2.1 改变了数据模型本身:现在每条消息都以有序的消息部件列表保存内容。一个部件就是响应中的一个片段,例如一段文本、一个推理块或一次工具调用。各提供商将这些片段称为内容块(Anthropic、Bedrock)、部件(Gemini)、项(OpenAI Responses)或分块(Mistral),Spring AI 将它们全部映射为消息部件。
在 2.1.0-RC1 候选版本中,主要聊天模型已经能够生成和消费消息部件:OpenAI Responses、Anthropic、Google GenAI、OpenAI Chat Completions、Ollama、Bedrock Converse、DeepSeek 和 Mistral AI。该版本还新增了 Session 记忆,可将这些部件持久化。本文将介绍这一新抽象、Responses API 为什么需要它,以及各个聊天模型从中获得了什么。归根结底就是三条规则:保持顺序、原样回放收到的内容、持久化部件而非文本。
为什么仅靠字符串不够
下面是在启用思考功能后,Claude 在单轮工具调用中返回的内容:
当你发送工具结果时,Anthropic 希望这一轮内容原封不动地传回:相同的块、相同的顺序,并包含签名。在 2.1 之前,AssistantMessage 无法做到这一点:
- 无法表达跨类型顺序。 文本、工具调用和媒体分别存放在不同字段中,因此回放时必须猜测顺序。
- 不透明数据无处存放。 签名和加密推理都是不透明的:客户端无法读取它们,且必须原样传回。这些内容此前要么位于提供商特定的元数据键下,要么位于包私有的子类中。
- 流式处理会丢失内容。 聚合器按元数据键逐个合并分块,因此思考内容通常只保留最后一个片段。
- 记忆会丢失内容。
JdbcChatMemoryRepository只存储文本和消息类型。
MessagePart 抽象
AbstractMessage 现在持有一个 parts: List<MessagePart>,所有消息类型共享这一结构:
[LOADING...]
MessagePart 是一个密封接口,包含六个 record 实现:
[LOADING...]
引入 MessagePart 实现了以下目标:
- 顺序本身就是数据。 部件会保持提供商生成它们时的顺序,因此思考块始终位于它引出的工具调用之前。
- 不透明载荷只回放,不解释。 一个部件可以携带
OpaquePayload(provider, kind, data),例如 Anthropic 的signature、Gemini 的thought_signature或 OpenAI 的encrypted_content。生成它的ChatModel会将其原样传回,其他所有聊天模型都会跳过它。这样历史记录在不同提供商之间迁移时也能保持安全。 - 不会静默丢弃任何内容。
ChatModel不理解的块会变成UnknownPart,并原样回放给生成它的提供商。
这一变更向后兼容。构造函数和构建器方法仍然可用,并按照 2.0 的顺序生成部件:文本、工具调用、媒体。getText()、getToolCalls() 和 getMedia() 现在是对部件的视图,与新增的 getReasoning() 并列,而 getParts() 会按顺序返回全部内容。最可能需要更新的是读取推理内容的代码,因为在 2.1 之前,每个聊天模型暴露推理内容的方式各不相同:
升级说明列出了各聊天模型剩余的变更。流式处理时,每个部件都会标记其内容块索引和 partial 标志,MessageAggregator 会合并增量。聚合后的消息与阻塞调用返回的消息相同,包括签名。
消息部件如何让 Responses API 成为可能
Spring AI 实现了两个 OpenAI 聊天模型端点。OpenAiChatModel 使用 Chat Completions,这是 OpenAI 最初的聊天 API,许多其他服务器也实现了它。2.1 新增的 OpenAiResponsesChatModel 使用 Responses API,这是 OpenAI 面向推理模型和智能体的较新 API,并采用其无状态模式。
Responses API 不会返回一条消息。每次调用都会返回一个由项组成的 output 数组:reasoning、function_call 和 message,以及 OpenAI 自行运行的工具所产生的项,例如网络搜索。每个推理项都带有自己的 id 和 encrypted_content。
OpenAiResponsesChatModel 在设计上是无状态的:它不会在 OpenAI 一侧保留对话,而是在每次调用时回放全部对话,因此记忆、Advisor 和 RAG 的工作方式与 OpenAiChatModel 完全相同。对于推理模型而言,只有当回放包含模型先前的推理时,效果才会好。OpenAI 的推理指南要求提供自最后一条用户消息以来的推理项、函数调用和函数调用输出。
在 2.1 之前,AssistantMessage 无法忠实保存这些项:
- 它无法表达“先推理,再函数调用,最后消息”这样的顺序。
- 它无法把数据附加到单个项上。每个推理项都有一个 id,并以加密形式(
encrypted_content)携带模型的推理内容,二者都必须随该项一起传回。而一条消息对于整个响应只有一个元数据映射。 - 它没有地方保存 OpenAI 自行运行的工具(例如网络搜索)所产生的项,除非把它们伪装成本地工具调用。
使用部件后,映射是一对一的,并会保持 output 数组的顺序:
在下一次请求中,聊天模型会按相同顺序从部件重建输入项。这种回放是值得的。OpenAI 现已归档的、关于推理模型提升性能的 cookbook 指出,在 SWE-bench 上包含推理项“带来了约 3% 的提升”。对于当前的 OpenAI 模型,Responses 也是智能体使用的端点。从 GPT-5.4 开始,Chat Completions 不支持在推理强度不是 none 时进行工具调用。
各个聊天模型获得了什么
其他所有聊天模型都遵循同样的模式:将每个响应块映射为一个部件,按顺序从部件重建请求,并且只回放自己的载荷。具体收益取决于各提供商的要求:
Anthropic
在工具使用轮次中,Claude 要求将其思考块传回,而且你“不能重排、编辑或部分丢弃它们”:被修改的块会被以 HTTP 400 拒绝。在 2.1 之前,Spring AI 会按“文本、思考、工具使用”的顺序重建该轮内容,而不管 Claude 实际使用了什么顺序。它还会丢弃服务端工具块,并且流式思考内容从未完整进入聚合消息。现在,每个块都是一个部件,会连同签名按顺序回放。Anthropic 还提到了另外两项收益:保留思考内容“让 Claude 能从上次中断的地方继续推理”,而保留思考块“可在工具使用期间实现缓存命中”。Claude Opus 4.5 及更高版本、Sonnet 4.6 及更高版本还会在上下文中保留先前轮次的思考内容,这正是 Session 记忆发挥作用的地方。
Google GenAI
Google 的思考签名指南要求将每个签名“返回到接收它的确切部件中”。在 Gemini 3 及更高版本上,函数调用缺少签名会导致请求“以 400 错误失败”。在其他部件上,“性能可能会下降”。Spring AI 以前会把所有签名收集到一个列表中,并按位置将它们重新附加到函数调用上。它还会从工具循环中丢弃函数调用之前的思考内容和文本。现在,每个签名都随其所在部件一起传递,代码执行和服务端工具部件也会按接收时的原样传回。
OpenAI Chat Completions
OpenAI 自己的 Chat Completions API 不返回推理文本,因此 OpenAI 推理模型应使用 Responses。OpenAiChatModel 服务于那些确实会返回 reasoning_content 的兼容服务器,例如 vLLM、gpt-oss 部署和 Kimi。OpenAI 的 harmony 指南要求在工具调用期间传回 gpt-oss 的思维链。这种回放在 2.0.x 中已经通过元数据键实现。现在它依托于部件,因此能在持久化后保留下来。由其他提供商签名的推理内容绝不会被转发;对于拒绝该字段的服务器,可以使用 replayReasoningContent(false)。
Ollama
在 2.1 之前,思考内容只存在于响应元数据中,从未被传回。流式处理时,只有最后一个片段得以保留。现在,它会按照 Ollama 工具调用文档的要求,回放到 assistant 消息的 thinking 字段中:“在后续请求中将那些字段与任何工具结果一起返回”。收益取决于模型。gpt-oss 和 qwen3 模板会使用当前工具循环中的推理内容。
Bedrock Converse
Bedrock 应用了 Claude 的规则:“在工具使用期间,你必须为最后一条 assistant 消息将思考块传回 API”。BedrockProxyChatModel 以前会丢弃流式响应中的推理内容。现在,一个响应就是按块顺序生成的一组部件,流式响应会携带推理内容、签名和脱敏推理内容,来自 gpt-oss 的未签名推理也会被回放。
DeepSeek
在思考模式下,携带工具的请求必须传回先前 assistant 消息的 reasoning_content,否则 API 会返回 400。在 2.1 之前,Spring AI 只会为 DeepSeekAssistantMessage 实例发送该内容,因此流式响应以及从记忆恢复的消息在发出时都不包含它。现在,任何 assistant 消息都会回放其 ReasoningPart,无论是否流式处理,并且 DeepSeekAssistantMessage 已弃用。
Mistral AI
Mistral 的推理文档写道:“始终回放完整的 assistant 消息(包括 ThinkChunk)”。他们警告说,丢弃推理轨迹会“降低模型性能”。在 2.1 之前,MistralAiChatModel 从不传回思考内容。现在,思考、文本、引用和图像分块都会成为部件,并按分块顺序回放,包括在工具循环的各轮之间。## 保留消息部件的记忆:Spring AI Session
在单次 ChatClient 调用内进行重放,只是故事的一半。调用之间,历史记录存放在聊天记忆中,而在 ChatMemory 的各个仓库中,只有内存实现会保留这些部件。使用其他任何实现时,模型在每次恢复的轮次中都会从头开始推理。
Spring AI Session 现在是 Spring AI 2.1 的一部分,补上了这一缺口。会话是一个仅追加的事件日志。每条消息都以其完整的部件列表形式存储,包括负载和属性,JDBC 存储会将它们保存为 JSON。JdbcChatMemoryRepository 是 ChatMemory 背后的 JDBC 存储,它只保留文本,并完全丢弃工具调用和工具结果。对于推理模型,还有两点很重要:
- 每一轮工具调用都会被记录。
SessionMemoryAdvisor在工具调用循环内部运行,因此它会存储每一轮的工具调用和结果,而不只是最终答案。每一轮的提示词都会从会话中重新构建,因此签名会经由存储完成往返。 - 压缩会遵循轮次边界。 当历史被裁剪或摘要时,切分点总会落在轮次边界上。工具调用绝不会丢失其结果,而它之前的推理也会与它保持在一起。
添加 spring-ai-starter-model-session-jdbc starter,注册会话 advisor,并在每次调用时传入会话 ID:
完成这些配置后,上述收益将跨调用和重启持续存在。Claude 仍能看到自己先前的思考,Gemini 会在正确的部件上取回每个签名,DeepSeek 的思考模式规则在重新部署后仍然成立。ChatMemory 及其仓库在 2.1 中已弃用,并将在 3.0.0 中移除。迁移指南 将一个 API 映射到另一个 API。
⚠️ 注意事项
每个响应只返回一个
Generation。 在 2.1 之前,Anthropic、Google GenAI 和 Bedrock Converse 会将一个响应拆分为独立的Generation,分别用于思考、工具调用和文本。现在它们会为每个候选返回一个。使用getResult().getOutput().getReasoning()读取推理内容。在 2.1 之前存储的历史记录没有推理部件。 早期版本写入聊天记忆的消息在返回时不会带有推理内容,因此不会被重放。
跨提供商的历史记录是安全的,但不是无损的。 由一个提供商签名的推理绝不会发送给另一个提供商。当你在对话中途切换模型时,文本和工具调用会保留下来,但另一家厂商的推理不会。
保留部件属性。 对于 Mistral,以及 Bedrock 的未签名推理,只有当部件的
provider属性明确指向它们时,才会重放推理。如果你自行存储历史记录,请不要剥离属性。
结论
消息部件将 Spring AI 消息从一段带附件的字符串,转变为对模型产出了什么以及以什么顺序产出的忠实记录。关键洞见:
- 顺序是数据的一部分。 推理模型会检查你发送回去的内容的顺序和完整性。仅靠一段字符串和旁列表,无法承载这两者。
- 原样重放你收到的内容。
OpaquePayload让每个聊天模型原样返回自己的签名,并跳过其他模型的签名。 - 持久化部件,而不是文本。 会话记忆会存储每一轮工具调用的每个部件,因此推理可以在跨调用和重启后继续存在。
资源
Spring AI
- 消息部件 和 升级到 2.1.0,包含各提供商的变更
- OpenAI Responses
- 会话记忆 和 迁移到会话记忆
相关 Spring AI 文章