Spring AI 2.1 Message Parts 深度实战:推理签名回放与跨轮记忆持久化的源码级落地

一、先讲一个不报错的 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"} }
  ]
}

三个硬约束必须记住:

  1. 顺序 :thinking 块必须在 tool_use 块之前。你不能把它挪到末尾,也不能把它塞进 metadata。
  2. 保真 :signature 是 Anthropic 用私钥签的,缺了报 Field required,改一个字符报 Invalid signature in thinking block。
  3. 交错思考 :开启 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);
    }
}

然后配三条告警:

  1. llm.reasoning.parts{has_signed="true"} 计数突降为 0 ------ 说明推理载荷在某处被吃了。
  2. 工具调用成功率环比下跌 > 10% ------ 静默降级的业务侧信号。
  3. 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 工程师的主场。

相关推荐
Solange__2 小时前
影视预演怎么用AI生成3D场景?从分镜、Blockout到镜头交付的完整流程
人工智能·游戏·3d·ai·自然语言处理·aigc·游戏策划
又过二十2 小时前
Qwen-Image2.1游戏素材工坊:一句话,生成一整套游戏美术素材(一)
ai·aigc·comfyui·game·qwen-image·游戏素材
thefool1122662 小时前
TCP 1
java·1024程序员节
known3 小时前
用 C# + Blazor 开发智能体(Agent)教程
ai·blazor
todoitbo4 小时前
在 Ubuntu ARM64 上运行 Q1 V3.0 MuJoCo 模型:加载、步进和单执行器响应
linux·运维·ubuntu·ai·机器人·仿真教程·启元
楚国的小隐士4 小时前
从审核员到监工:AI正在重写评价体系,也重写人的价值
ai·编程·ai编程·哲学·马列主义
金銀銅鐵4 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(下)
java·后端·ai编程
AI人工智能+电脑小能手5 小时前
一次请求打几个下游,这个Java并行任务工具类6个方法按序聚合
java·线程池·并发编程·1024程序员节·批量处理·并行任务
俊哥V5 小时前
每日 AI 研究简报 · 2026-10-24
人工智能·ai·1024程序员节