一、先讲一个不报错的 bug
去年底到今年,我接手过三个从 Demo 转生产的 Agent 项目,它们身上都出现了同一个症状:
第一轮回答得像个资深架构师,第五轮开始像个实习生。
更诡异的是:日志里没有任何异常,HTTP 200,token 消耗正常,链路追踪一片绿色。你去看那一轮的消息历史,user → assistant → tool → assistant,结构完全合法。
这个 bug 的根因只有一句话:你的代码把模型的"思考过程"扔了。
在 Chat Completions 时代这没什么问题,因为普通模型本来也不返回思考过程。但现在你用的是推理模型(reasoning model)------Claude 的 extended thinking、OpenAI 的 o 系列和 GPT-5.6、DeepSeek-R1、Qwen3、MiniMax M2。这些模型在多轮工具调用场景下,每一步之间都会产出一段带签名的推理状态 ,并且要求你在下一轮请求时把它原封不动还回去。
这个"还回去"的动作,在 2026 年之前是所有 Java Agent 框架的盲区,包括 Spring AI 2.0。原因很直接:Spring AI 2.0 的 AssistantMessage 是一个平铺结构 ------String text + List<Media> + List<ToolCall>。这个结构从设计上就无法表达"thinking 块 → tool_use 块 → text 块"这种有序交错关系,更无法承载那种几 KB 的加密签名。
于是框架只能做一件事:把 thinking 内容丢弃。丢掉之后:
- Anthropic :下一次请求直接 400,报
messages.1.content.0.thinking.signature: Field required。它还算仁慈,至少让你知道出事了。 - OpenAI Responses API :
store=false(零数据留存)时返回encrypted_content,不带回去模型就无法延续推理链。 - 开源推理模型 (DeepSeek / Qwen / MiniMax): 不报错,请求照常成功,只是模型悄悄变笨。因为它在第 5 步回头看历史,发现"我前面四步连想都没想就瞎调了一通工具",这严重偏离训练分布,推理质量断崖下跌。MiniMax 官方在 2025 年底专门发文提醒过接入方。
OpenAI 官方做过对照实验:同样是 GPT-5,保留多步之间的推理状态,在 TAUBench 复杂任务基准上比传统"每次丢掉"的接法高 5 个百分点。
Spring AI 2.1 正是为了解决这个问题而来。它的核心变更不是加了几个 API,而是把 Message 从一个扁平字符串容器,改成了一个有序的 Part 列表------这是结构性变更。
这篇我们就把这件事从 models 层语义、Spring AI 源码契约、到生产级持久化方案,完整拆一遍。

二、为什么"平铺模型"必须死:三家 provider 的真实语义
在动手写代码前,必须先把三家 provider 的推理载荷语义搞清楚。因为它们互不兼容,这直接决定了你的持久化设计。
2.1 Anthropic:硬校验,一个字符都不能改
Claude 开启 extended thinking 并调用工具时,Anthropic API 是完全无状态 的,服务端不存任何东西。它要求客户端在下一轮把上一轮返回的所有 thinking 块原样带回,且 signature 必须逐字一致:
json
{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "I should call the weather tool.", "signature": "EqQBCkYIBRgC..." },
{ "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Kraków"} }
]
}
三个硬约束必须记住:
- 顺序 :thinking 块必须在 tool_use 块之前。你不能把它挪到末尾,也不能把它塞进 metadata。
- 保真 :signature 是 Anthropic 用私钥签的,缺了报
Field required,改一个字符报Invalid signature in thinking block。 - 交错思考 :开启 interleaved thinking 后,一个 assistant turn 里可能有多个 thinking 块,且
budget_tokens表示的是这个 turn 内所有 thinking 块的总预算 ,不是单块预算------所以它有时会超过max_tokens,这不是 bug。
还有一条容易被忽略的限制:extended thinking 与强制工具选择不兼容 。tool_choice 只能是 {"type":"auto"} 或 {"type":"none"},{"type":"any"} 和 {"type":"tool", "name":...} 会被直接拒绝。Anthropic 的理由是一致性------模型是在推理过程中决定调哪个工具的,强制指定等于掐断它的决策过程。
另外 Claude 4 系列有个"思考摘要"层:API 默认返回的是用小模型压缩过的 summary,但计费按完整 thinking token 算。所以你看到的 summary token 数和账单上的 output token 数对不上,这是正常的,别去查账。
2.2 OpenAI:服务端存 or 客户端带回,二选一
OpenAI Responses API 提供两条路:
| 策略 | 做法 | 坑 |
|---|---|---|
previous_response_id |
下一轮只传新 input + 上一轮 response.id | 不携带上一轮的 top-level instructions,稳定指引必须每轮重发 |
| 手动 Item 回放 | 把上一轮全部 output 连同你的 function_call_output 拼进 input |
可裁剪,但绝不能丢 reasoning Item 和未闭合的 call |
在 store=false(或组织强制零数据留存 ZDR)时,走的是第二条路。此时 reasoning item 长这样:
json
{ "id": "rs_...", "type": "reasoning", "summary": [], "encrypted_content": "gAAAA...", "status": null }
必须把所有 output item(包括 reasoning )回放,并把 reasoning.context 设为 all_turns;如果重放的是较早的、模型已经不需要的元素,用 current_turn 可以降低渲染上下文。
还有一个 2026 年新增的细节:在 gpt-5.5 / gpt-5.4 的长流程或工具密集场景,建议给每个 assistant message 标 phase(commentary 表示中间态,final_answer 表示完成)。不标的话,模型可能把一段"我准备先查日志"的开场白当成最终答案,然后提前收工。手动回放历史时,每轮的 phase 值要一并保留。
2.3 开源推理模型:静默降级,最阴的一种
DeepSeek、Qwen、MiniMax 这类模型返回的是明文的 reasoning_content,没有签名、没有校验。你丢了它,API 不会报错。
这就是最危险的地方:线上不出错,质量悄悄滑坡。而且这种滑坡是渐进的------第 2 轮损失 5%,第 8 轮可能就答非所问了。你的监控看不到 5xx,你的 P99 延迟甚至更好(因为上下文变短了)。
唯一能抓住它的手段是业务指标:工具调用成功率、多步任务完成率、人工介入率。这些指标一旦出现"上线新版本后缓慢下跌",第一件事就该去查推理载荷有没有丢。
三、源码级:Spring AI 2.1 的 MessagePart 体系
搞清楚语义,再看 Spring AI 是怎么接住的。
3.1 Part 的类型层级
Message 内部从"平铺字段"改为有序的 List<MessagePart>,按 provider 返回的原始顺序保存。具体实现类:
| Part 类型 | 承载内容 |
|---|---|
TextPart |
普通文本片段 |
ReasoningPart |
推理内容 + summary + signature + payload |
ToolCallPart |
一次工具调用请求(id / type / name / arguments) |
ToolResultPart |
工具执行结果 |
MediaPart |
图片/音频等媒体 |
UnknownPart |
框架不认识的新类型,原样透传 |
UnknownPart 这个设计值得单独说一句:provider 上新能力的速度永远快过框架发版速度,有了这个兜底,至少不会因为遇到没见过的 part 类型就 500。
3.2 兼容性:getText() 是视图,不是字段
这是本次升级最关键的设计决策------老代码不用改 。getText() 和 getMedia() 保留,但它们从"存储字段"变成了"对 parts 的投影视图":
java
AssistantMessage turn = AssistantMessage.builder()
.part(new ReasoningPart("Need the weather first.", "Plans a lookup", signature, Map.of()))
.part(ToolCallPart.of(new ToolCall("call_1", "function", "getWeather", "{\"city\":\"Mumbai\"}")))
.part(TextPart.of("Checking the weather for Mumbai."))
.build();
turn.getParts(); // [ReasoningPart, ToolCallPart, TextPart] ------ 顺序与 provider 一致
turn.getText(); // "Checking the weather for Mumbai." ← 只有 text 部分
turn.getToolCalls(); // size = 1
turn.getReasoning(); // size = 1
注意 getText() 只返回 text 部分------它天然丢 reasoning 。这就是为什么你从 1.x/2.0 迁过来时,所有形如 new AssistantMessage(response.getResult().getOutput().getText()) 的代码都是定时炸弹。
迁移时的第一件事:全局搜索 getText(),逐个判断它是不是在"重建消息"。 如果是,改成直接搬 AssistantMessage 对象本身。
3.3 跨 provider 回放门禁:replayableTo()
一个带签名的 ReasoningPart 只对签发它的 provider 有意义。Spring AI 给了个判断方法:
java
ReasoningPart signed = ...;
signed.replayableTo("openai"); // true
signed.replayableTo("anthropic"); // false ------ 签名不是它签的
unsigned.replayableTo("openai"); // true ------ 无签名的明文推理,谁都能收
底层承载是一个 OpaquePayload:
OpaquePayload[provider=openai, kind=reasoning.encrypted_content, data=gAAAA-...]
"Opaque"这个词用得很准------框架不解密、不解析、不重排这个 payload,只是搬运。这是唯一正确的做法,因为任何形式的"理解"都会破坏字节一致性。
replayableTo 的实际用法就一个:多 provider 路由场景下,把不属于当前目标的签名 part 过滤掉。这是 2.1.0-RC1 明确提到的能力------"保留 provider 特有的推理载荷,回放行为对 OpenAI 兼容端点可配置,因为有些端点要求回放,有些端点会拒绝回放"。
3.4 流式:partIndex 与 partial 两个记账属性
流式场景下,一个 text part 是分片到达的。Spring AI 用两个 attribute 做标记:
partIndex=0 partial=true attributes={partIndex=0, partial=true} ← "Hel"
partIndex=0 partial=true attributes={partIndex=0, partial=true} ← "lo"
partIndex=0 partial=false attributes={partIndex=0} ← 完成态
strip(done).attributes = {} ← 清掉记账属性后可直接入库
StreamingParts.strip() 把这两个内部记账属性摘掉,剩下的就是干净可持久化的 part。
这里有个生产上非常容易踩的坑:流式的最后一个 chunk 往往是"只有加密 reasoning、没有任何可见文本"的。如果你的 SSE 聚合逻辑是"拼 text chunk 成字符串然后存库",那这段 payload 永远进不了历史。这也是 .NET / Python 生态里最常见的泄露点,Java 这边一样会犯。
四、实战项目:AgentTrace ------ 企业售后多轮推理 Agent
理论讲完,上项目。
场景:企业售后工单 Agent。用户一句话进来,Agent 需要多轮调用工具(查订单 → 查物流 → 判断是否超时 → 计算赔付 → 提交工单),每一步之间都有模型推理。
技术栈 :Spring Boot 4.2.0-RC1 + Spring AI 2.1.0-RC1(父 POM 与 BOM 版本对齐,RC1 的父工程 spring-boot.version 就是 4.2.0-RC1)+ JDK 21 + PostgreSQL 16。
跑通环境是 JDK 21.0.12.1、Maven 3.9.9,这个组合官方验证过。
4.1 依赖
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.2.0-RC1</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.1.0-RC1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
配置:
properties
# 走 Responses API,Chat Completions 无法在开启 reasoning effort 的同时做工具调用
spring.ai.openai.chat.api=responses
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-5.6
spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
spring.ai.anthropic.chat.options.model=claude-sonnet-4-5
spring.ai.openai.chat.api=responses 这一行是必须 的。原因很硬:Chat Completions 端点无法在开启工具调用的同时使用非 none 的 reasoning effort 。Responses API 才可以。而目前(2.1.0-M1/RC1 阶段)OpenAiResponsesChatModel 也是原生产出 message parts 的模型实现,RC1 已经把这个能力铺到主要 chat provider。
4.2 第一层:Turn 装配器 ------ 绝不重建消息
整个项目的第一条铁律:assistant turn 只搬运,不重建。
java
package com.acme.agenttrace.memory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.MessagePart;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class TurnAssembler {
/**
* 把 provider 返回的 assistant 消息原样装配成可持久化的 turn。
* 关键:part 顺序、signature、opaque payload 全部不做任何加工。
*/
public AssistantMessage assemble(ChatResponse response) {
if (response == null || response.getResult() == null) {
throw new IllegalStateException("empty chat response, nothing to assemble");
}
AssistantMessage raw = response.getResult().getOutput();
List<MessagePart> parts = raw.getParts();
if (parts == null || parts.isEmpty()) {
throw new IllegalStateException(
"provider returned 0 parts, metadata=" + raw.getMetadata());
}
AssistantMessage.Builder builder = AssistantMessage.builder();
for (MessagePart part : parts) {
builder.part(part); // 顺序沿用 provider 原始顺序
}
return builder.properties(raw.getMetadata()).build();
}
}
注意这里没有 getText(),也没有 new AssistantMessage(...)。这是刻意的------一旦有人在这里"简化"一下,整条推理回放链路就断了,而且断得悄无声息。
4.3 第二层:持久化 ------ 为什么内置 JDBC 仓储不够用
这是 2.1 迁移中最容易被忽略的真实 gap。
2.1.0-M1 阶段的状态是:InMemoryChatMemoryRepository 存的是 message 对象本身 ,所以 part、payload 全都活下来了;但基于 JDBC 的持久化仓储还不存 parts,reload 回来后 reasoning 就没了。
这不是"配置没写对",这是当前版本的能力边界。同一个 turn,走内存仓储 round-trip 后 reasoning parts = 1、payload 保留;走 JDBC 仓储 reload 后归零。
RC1 带来了内置 session memory(append-only 事件日志 + 在 turn 边界切分的压缩视图,JDBC 侧支持 PostgreSQL / MySQL / MariaDB / H2,且每次调用必须显式带 session id;老的 chat-memory API 已标记在 3.0.0 移除),但要真正做到"推理载荷跨轮、跨实例、跨重启都不丢",在多 provider 场景下自己接管 part 级持久化仍然是最稳的选择。
表结构(PostgreSQL):
sql
CREATE TABLE agent_turn_part (
session_id varchar(64) NOT NULL,
turn_seq bigint NOT NULL,
part_index int NOT NULL,
role varchar(16) NOT NULL,
part_type varchar(32) NOT NULL, -- text / reasoning / tool_call / tool_result / media / unknown
provider_id varchar(32), -- openai / anthropic / deepseek ...
signed boolean NOT NULL DEFAULT false,
text_content text,
summary_content text,
signature text, -- 逐字保存,禁止任何 trim / normalize
payload_kind varchar(64), -- reasoning.encrypted_content / thinking ...
payload_data text, -- opaque,不解码
attributes jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (session_id, turn_seq, part_index)
);
-- 回放时按 session + turn 顺序整段取出,一条 SQL 搞定
CREATE INDEX idx_turn_part_replay ON agent_turn_part (session_id, turn_seq, part_index);
写库代码的关键点:signature 和 payload_data 用单独的列,不要序列化进一个大 JSON 。为什么?因为一旦整体 JSON 化,Jackson 的默认配置会做几件事:null 字段省略、字符串 trim、Map 顺序重排。任何一项都会让 signature 不再是逐字节相同,Anthropic 立刻 400。
java
package com.acme.agenttrace.memory;
import org.springframework.ai.chat.messages.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class TurnPartRepository {
private final JdbcTemplate jdbc;
public TurnPartRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void saveTurn(String sessionId, long turnSeq, String role, List<MessagePart> parts) {
if (parts == null || parts.isEmpty()) {
return;
}
List<Object[]> rows = new ArrayList<>(parts.size());
for (int i = 0; i < parts.size(); i++) {
// 用下标而不是 indexOf():重复 part(比如两个空 TextPart)会取错序号
rows.add(mapRow(sessionId, turnSeq, role, i, parts.get(i)));
}
jdbc.batchUpdate("""
INSERT INTO agent_turn_part
(session_id, turn_seq, part_index, role, part_type, provider_id,
signed, text_content, summary_content, signature,
payload_kind, payload_data, attributes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?::jsonb)
ON CONFLICT (session_id, turn_seq, part_index) DO NOTHING
""", rows);
}
private Object[] mapRow(String sessionId, long turnSeq, String role, int idx, MessagePart p) {
if (p instanceof ReasoningPart rp) {
return new Object[]{
sessionId, turnSeq, idx, role, "reasoning",
rp.getPayload() != null ? rp.getPayload().provider() : null,
rp.getPayload() != null,
rp.getText(), rp.getSummary(), rp.getSignature(), // 三个字段一律原样落库
rp.getPayload() != null ? rp.getPayload().kind() : null,
rp.getPayload() != null ? rp.getPayload().data() : null,
"{}"
};
}
if (p instanceof ToolCallPart tcp) {
ToolCall tc = tcp.getToolCall();
return new Object[]{
sessionId, turnSeq, idx, role, "tool_call", null, false,
null, null, null, tc.id(), tc.name(), toJson(tc.arguments())
};
}
if (p instanceof TextPart tp) {
return new Object[]{sessionId, turnSeq, idx, role, "text", null, false,
tp.getText(), null, null, null, null, "{}"};
}
if (p instanceof ToolResultPart trp) {
return new Object[]{sessionId, turnSeq, idx, role, "tool_result", null, false,
trp.getText(), null, null, null, trp.getToolCallId(), "{}"};
}
// UnknownPart 等:整包落 attributes,未来 provider 加字段也不会丢
return new Object[]{sessionId, turnSeq, idx, role, "unknown", null, false,
null, null, null, null, null, toJson(p)};
}
private String toJson(Object o) {
try {
return MAPPER.writeValueAsString(o);
} catch (Exception e) {
throw new IllegalStateException("part serialize failed", e);
}
}
private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
}
ON CONFLICT DO NOTHING 是幂等保护:Agent 循环里如果因为重试导致同一个 turn 被写两次,不会把 part 顺序写乱。
4.4 第三层:压缩 ------ 只裁明文,永不裁签名
上下文窗口是要管的。但压缩必须区分对待:
java
public List<Message> compactForReplay(List<Message> window, int tokenBudget) {
int total = countTokens(window);
if (total <= tokenBudget) {
return window;
}
List<Message> kept = new ArrayList<>();
// 从最新往回留,留到预算为止
for (int i = window.size() - 1; i >= 0 && total > tokenBudget; i--) {
Message m = window.get(i);
if (m instanceof AssistantMessage am && isLastToolTurn(am)) {
kept.add(0, m); // 与未闭合工具调用相邻的 turn 不动
continue;
}
Optional<Message> shrunk = shrinkUnsignedOnly(m, tokenBudget);
shrunk.ifPresent(s -> kept.add(0, s));
total = countTokens(kept);
}
return kept;
}
/**
* 只对"无签名"的 part 做摘要压缩。
* 一旦发现带签名的 ReasoningPart 被要求丢弃 ------ 直接抛,宁可报错也不要静默降级。
*/
private Optional<Message> shrinkUnsignedOnly(Message m, int budget) {
if (!(m instanceof AssistantMessage am)) {
return Optional.of(m);
}
boolean hasSigned = am.getReasoning().stream()
.anyMatch(rp -> rp.getPayload() != null || rp.getSignature() != null);
if (hasSigned) {
throw new IllegalStateException(
"refuse to compact signed reasoning turn; cut at turn boundary instead");
}
return Optional.of(TextSummary.of(am)); // 只摘要明文 text part
}
这里的 throw 是故意的 fail-fast。宁可让一次请求失败、让告警响起来,也不要让一个"上下文变短了但没人发现"的历史悄悄流进模型。
正确的降级姿势是在 turn 边界切分 ------RC1 的 session memory 正是这么做的:只在 turn 边界压缩,保证一个 turn 内的 part 序列完整。工具调用与结果是一对,切开必然触发 tool_use ids were found without tool_result blocks 这类错误。
4.5 第四层:跨 provider 回放门禁
多 provider 路由下(成本/合规/可用性),历史里会混着不同家的签名。回放前必须过滤:
java
@Component
public class ReplayGate {
/**
* 过滤掉当前 provider 无法接收的签名推理 part。
* 注意:同 provider 内绝不过滤,过滤了就 400;
* 跨 provider 必须过滤,不过滤了也 400(或模型不认)。
*/
public List<Message> filter(List<Message> history, String targetProviderId) {
List<Message> out = new ArrayList<>(history.size());
for (Message m : history) {
if (m instanceof AssistantMessage am) {
List<MessagePart> kept = new ArrayList<>();
for (MessagePart p : am.getParts()) {
if (p instanceof ReasoningPart rp && !rp.replayableTo(targetProviderId)) {
log.warn("drop foreign signed reasoning part: provider={} target={}",
rp.getPayload() == null ? "unknown" : rp.getPayload().provider(),
targetProviderId);
continue;
}
kept.add(p);
}
AssistantMessage.Builder b = AssistantMessage.builder();
kept.forEach(b::part);
out.add(b.properties(am.getMetadata()).build());
} else {
out.add(m);
}
}
return out;
}
}
配合一个"错误分类 + 换 provider"的兜底:
java
public String chatWithFailover(String sessionId, String userInput) {
List<String> candidates = List.of("openai", "anthropic", "deepseek");
RuntimeException last = null;
for (String provider : candidates) {
try {
List<Message> history = replayGate.filter(memory.load(sessionId), provider);
ChatResponse resp = routingClient.call(provider, history, userInput);
AssistantMessage turn = assembler.assemble(resp);
memory.append(sessionId, turn);
return turn.getText();
} catch (NonTransientAiException e) {
// 签名失效 / tool_use 不配对:不重试,直接换 provider 并从当前轮重建上下文
log.error("provider={} non-retryable: {}", provider, e.getMessage());
last = e;
} catch (TransientAiException e) {
// 429 / 5xx / 超时:当前 provider 重试两次,仍失败再换
if (!retryTwice(provider, sessionId, userInput)) {
last = e;
} else {
return "";
}
}
}
throw new IllegalStateException("all providers failed", last);
}
关键判断:签名类错误绝不重试。 重试只会用同样的坏历史再撞一次墙,白白多烧两次 token。这类错误必须归类为 non-transient,立刻换路或开新 session。
4.6 第五层:流式聚合 ------ 按 partIndex 归并,只在完成时 commit
前端流式输出和"入库的历史"必须分开对待。前端只要 text,入库要全量。
java
public class PartAccumulator {
private final Map<Integer, List<MessagePart>> byIndex = new TreeMap<>();
private final StringBuilder visible = new StringBuilder();
/** 每个 chunk 都进来,不丢任何一个 */
public void accept(MessagePart part) {
int idx = readIntAttribute(part, "partIndex", 0);
boolean partial = readBoolAttribute(part, "partial", false);
byIndex.computeIfAbsent(idx, k -> new ArrayList<>()).add(part);
if (part instanceof TextPart tp) {
visible.append(tp.getText());
pushToSse(tp.getText()); // 只有 text 推给前端
}
// ReasoningPart 不推给前端 ------ 但必须留在 byIndex 里
}
/** 流结束后才 commit 到历史 */
public AssistantMessage commit() {
AssistantMessage.Builder b = AssistantMessage.builder();
byIndex.values().forEach(list -> {
MessagePart done = StreamingParts.strip(list.get(list.size() - 1));
b.part(done);
});
return b.build();
}
private int readIntAttribute(MessagePart p, String key, int def) {
Object v = p.getAttributes() == null ? null : p.getAttributes().get(key);
return v instanceof Number n ? n.intValue() : def;
}
private boolean readBoolAttribute(MessagePart p, String key, boolean def) {
Object v = p.getAttributes() == null ? null : p.getAttributes().get(key);
return v instanceof Boolean b ? b : def;
}
}
两个细节:
TreeMap保证 part 顺序按 index 归并,即使网络乱序也不会把 thinking 排到 tool_call 后面。commit()只在流正常结束后调用。流中断时不入库------半截的 reasoning part 是最毒的数据,下次回放必然 400。
4.7 可观测:把"静默降级"变成指标
既然最危险的是不报错的降级,就必须用指标兜住:
java
@Component
public class ReasoningMetrics {
private final MeterRegistry registry;
public ReasoningMetrics(MeterRegistry registry) {
this.registry = registry;
}
public void record(ChatResponse resp, String provider, String sessionId) {
registry.counter("llm.reasoning.parts",
"provider", provider,
"has_signed", String.valueOf(hasSigned(resp)))
.increment(resp.getResult().getOutput().getReasoning().size());
// reasoning token 单独计量:Claude 的 summary 长度 ≠ 计费 token 数
Long reasoningTokens = extractReasoningTokens(resp.getMetadata());
if (reasoningTokens != null) {
registry.summary("llm.reasoning.tokens", "provider", provider)
.record(reasoningTokens);
}
}
/** 从 provider usage 明细里取 reasoning token;取不到返回 null,不要瞎猜 0 */
private Long extractReasoningTokens(ChatResponseMetadata metadata) {
Map<String, Object> native1 = metadata.getUsage() == null
? null : metadata.getUsage().getNativeUsage();
if (native1 == null) {
return null;
}
// OpenAI Responses: usage.output_tokens_details.reasoning_tokens
Object details = native1.get("output_tokens_details");
if (details instanceof Map<?, ?> m && m.get("reasoning_tokens") instanceof Number n) {
return n.longValue();
}
return null;
}
private boolean hasSigned(ChatResponse resp) {
return resp.getResult().getOutput().getReasoning().stream()
.anyMatch(rp -> rp.getSignature() != null || rp.getPayload() != null);
}
}
然后配三条告警:
llm.reasoning.parts{has_signed="true"}计数突降为 0 ------ 说明推理载荷在某处被吃了。- 工具调用成功率环比下跌 > 10% ------ 静默降级的业务侧信号。
- 400 错误中
signature相关占比 > 0 ------ 回放链路有保真问题。
第 1 条是这套体系里最有价值的告警,因为它比业务指标早发现几个小时。
五、生产踩坑清单(10 条,真实疼痛)
1. getText() 重建消息,推理载荷全丢。
new AssistantMessage(output.getText()) 是 2020 年的写法。2.1 里它等价于"主动丢弃 reasoning"。而且不报错。全局 grep 一遍,这是迁移的第一优先级。
2. 内置 JDBC 仓储不存 parts,reload 后 reasoning 归零。
2.1.0-M1 的真实边界。别以为配了 JDBC 就万事大吉------必须实测一次 round-trip,打日志确认 getReasoning().size()。
3. signature 落库时被 trim / normalize。
任何 String.trim()、str.replace("\n","")、ObjectMapper 默认配置的 null 省略,都会让字节不一致。Anthropic 只认它签的那份,改一个字符就 Invalid signature in thinking block。落库前不要碰这个字段。
4. 压缩上下文时把 signed reasoning 裁了。
这是最隐蔽的自杀行为:请求成功,质量下降。压缩只能在 turn 边界做,且必须跳过带签名的 turn。
5. 流式 commit 早了,入库半截 part。
SSE 的最后一个 chunk 常常是"只有加密 reasoning、无可见文本"。如果你的循环是"有 text 才处理",这段 payload 永远进不了历史;如果流中断还 commit 了,就是半截数据。只在流正常终止时 commit。
6. 跨 provider 回放没做门禁。
OpenAI 的 reason 包发到 Anthropic,轻则被忽略,重则 400。反过来也是。必须调 replayableTo 过滤,并且记住 RC1 的补充:回放行为对 OpenAI 兼容端点可配置------有些网关要求回放,有些直接拒收,要按端点能力配。
7. 开 extended thinking 又用了强制 tool_choice。
{"type":"any"} / {"type":"tool","name":...} 在 thinking 开启时会被直接拒绝。整个 Agent 循环的设计前提就变成:工具选择权必须交给模型。想强制?关掉 thinking,二选一。
8. budget_tokens 当成"每块预算"来配。
开了 interleaved thinking 后,它是一个 assistant turn 内所有 thinking 块的总预算。按块数乘法去配,会直接把 max_tokens 撑爆,或者反过来每块都不够用、思考被打断。
9. reasoning token 计费口径搞错,成本模型全歪。
Claude 4 返回 summary,但按完整 thinking token 计费;summary.length 和账单对不上。OpenAI 侧 reasoning token 也是按 output token 计价。成本看板必须单独拆一列,否则你以为在省钱,其实在为每次"想多了"付费。
10. 把 reasoning 明文写进日志和审计表。
推理内容可能包含用户隐私、内部业务判断、甚至是注入进上下文的敏感数据。审计日志只记 part 类型、数量、token 数、是否签名;正文一律脱敏。 这条不是技术问题,是合规问题。
六、迁移路径:现在该不该动
给一份务实的判断表:
| 你的现状 | 建议 |
|---|---|
| 纯文本问答,无工具调用,无非推理模型 | 不动。老 accessor 够用,升级零收益 |
| 有工具调用,用的是 DeepSeek/Qwen 等开源推理模型 | 尽快动。你此刻大概率正在静默降级,只是没人发现 |
| 用 Claude extended thinking + 工具 | 必须动。已经是 400 或即将 400 |
用 OpenAI Responses API + store=false |
必须动。ZDR 场景下不回放 reasoning 等于每次重启推理链 |
| 多 provider 路由网关 | 动,但要一套门禁。跨商回放是最难的部分 |
版本节奏上,2.1 线还在 milestone/RC 阶段:M1 建立了 MessagePart 结构但 provider 覆盖不全、持久化有 gap;RC1 把 parts 铺到主要 chat provider,并补上内置 session memory、query-vector 检索、工具调用确认、以及"可配置的推理载荷回放"。
所以务实的做法是:先把"不重建消息"这条铁律落到代码里 (这部分在 2.0 也能用兼容姿势做),再等 GA 切 parts 持久化。不要在生产上追 M 版本的新 API,但一定要现在就修掉"丢推理"的逻辑------那个 bug 已经在跑了。
七、总结
Spring AI 2.1 的 Message Parts,表面上是"消息结构从平铺改成列表",实质是框架终于承认了一件事:模型返回的不只是文本,还有必须被完整保真的状态。
这个认知转变带来的工程后果是连锁的:
- 消息不能重建 ,只能搬运------一旦你从
getText()重新组装,保真就断了。 - 持久化不能整体 JSON 化,关键字段要独立列------序列化框架的"善意规范化"就是签名杀手。
- 压缩不能只看 token 数,要看 part 的语义------带签名的推理是不可压缩的。
- 降级不能靠异常发现,要靠指标------最贵的那种 bug 是 200 OK 的 bug。
- 多 provider 不能共享签名 ,要门禁------
replayableTo就是那道闸。
做了十几年 Java 后端,我越来越觉得这套东西对 Java 工程师来说不是"新知识",而是"老手艺":字节保真、幂等写入、fail-fast 校验、边界切分、可观测兜底------这些是我们在支付系统、消息队列、分布式事务里磨了十几年的东西。
AI 应用落地到生产,最后卡住的从来不是"怎么调模型",而是"怎么保证一个不可信的外部系统返回的东西,在你的系统里被正确、完整、可审计地保存和重放"。
这件事,恰好是 Java 工程师的主场。