Skip to content

Spring AI Think 推理过程解析与存储

推理模型的响应并不总是只有最终答案。有的供应商把推理放进 reasoningContent 一类 metadata,有的 OpenAI 兼容接口把它包在 <think>...</think> 文本里;流式输出还会把一个标签切在多个 chunk 中。若直接把原始响应返回客户端或写入聊天记忆,会出现 UI 泄露、历史污染和 token 成本失控。

本文以 Spring AI 2.0 的 ChatClientCallAdvisorStreamAdvisor 为基线,给出一个统一处理方案。示例使用通用类名,复制前请按当前 Spring AI 版本核对 API。

1. 先定边界:解析不等于长期存储

需要先把三种数据分开,否则“存储推理过程”很容易演变为把所有内部文本写进会话数据库。

数据生命周期推荐位置是否回灌给下一轮模型
最终回答对用户可见AssistantMessage / API 的 answer可以,由 ChatMemory 按窗口保存
原始推理内容单次调用的临时处理数据ChatClientResponse.context() 或受限观测系统不应默认回灌
面向用户的解释摘要业务层主动生成的可见内容独立响应字段或业务表依产品需求决定

模型提供的原始 reasoning 不是可靠的审计事实,也不应被当作模型决策正确性的证明。生产系统通常应默认不持久化原始推理;若法规、排障或评测确实需要保留,应单独设计访问控制、加密、脱敏、保留期和删除流程,而不是复用 ChatMemory 表。

2. 响应来源与优先级

同一模型的不同适配器可能使用不同字段。推荐按以下顺序读取:

  1. 本轮响应 context 中已经由上游处理器写入的推理内容。
  2. AssistantMessage.metadata 中的 reasoningContentreasoning_contentthinkingContentthinkingreasoning
  3. 文本中的 <think>...</think>,作为兼容 OpenAI 协议的回退。

metadata 与文本同时存在时,应以 metadata 为准,避免将同一段推理重复拼接。无论来源是什么,最终答案都必须移除 <think> 块,避免直接透传给前端。

java
public record ChatResult(String answer, String reasoning) {
}

public final class ThinkingExtractor {
    private static final Pattern THINK_BLOCK = Pattern.compile(
            "<think\\s*>(.*?)(?:</think\\s*>|$)",
            Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

    private static final List<String> KEYS = List.of(
            "reasoningContent", "reasoning_content", "thinkingContent",
            "thinking", "reasoning");

    public static ChatResult extract(AssistantMessage message) {
        String text = message == null || message.getText() == null ? "" : message.getText();
        Map<String, Object> metadata = message == null ? Map.of() : message.getMetadata();
        String metadataReasoning = KEYS.stream()
                .map(metadata::get)
                .filter(Objects::nonNull)
                .map(String::valueOf)
                .filter(value -> !value.isBlank())
                .findFirst()
                .orElse(null);
        Matcher matcher = THINK_BLOCK.matcher(text);
        String textReasoning = matcher.find() ? matcher.group(1).trim() : null;
        String answer = THINK_BLOCK.matcher(text).replaceAll("");
        return new ChatResult(answer, metadataReasoning != null ? metadataReasoning : textReasoning);
    }
}

上例为了聚焦主线,省略了 metadata key 的大小写兼容;接入多个供应商时可遍历 entrySet() 做不区分大小写的匹配。

3. 为什么流式不能复用正则

同步响应已是完整字符串,正则可以处理;流式则可能按任意边界到达:

text
chunk 1: <thi
chunk 2: nk>分析中
chunk 3: </think>最终答案

如果对每个 chunk 独立执行正则,前两个 chunk 都无法识别标签,<think> 及内部文本就会泄露。正确做法是为每次 HTTP 流创建一个有状态解析器,保存尚未决定归属的尾部文本,并维护 inThink 状态。

其状态机很小:

text
普通文本 --发现完整 <think>--> 推理文本
推理文本 --发现完整 </think>--> 普通文本
任一状态 --chunk 结尾是标签前缀--> 保留到下一个 chunk
流结束 --未闭合 think--> 将剩余文本按推理处理

实现的关键不是扫描整个流,而是只保留可能组成标签的短尾部。这样既保证首 token 延迟,也不会无界缓存回答文本。

java
public final class StreamingThinkParser {
    private final StringBuilder pending = new StringBuilder();
    private final StringBuilder reasoning = new StringBuilder();
    private boolean inThink;

    public String accept(String chunk) {
        pending.append(chunk == null ? "" : chunk);
        StringBuilder visible = new StringBuilder();
        while (!pending.isEmpty()) {
            if (inThink) {
                int close = pending.indexOf("</think>");
                if (close < 0) {
                    int safe = safePrefixLength(pending, "</think>");
                    reasoning.append(pending, 0, safe);
                    pending.delete(0, safe);
                    break;
                }
                reasoning.append(pending, 0, close);
                pending.delete(0, close + "</think>".length());
                inThink = false;
            }
            else {
                int open = pending.indexOf("<think>");
                if (open < 0) {
                    int safe = safePrefixLength(pending, "<think>");
                    visible.append(pending, 0, safe);
                    pending.delete(0, safe);
                    break;
                }
                visible.append(pending, 0, open);
                pending.delete(0, open + "<think>".length());
                inThink = true;
            }
        }
        return visible.toString();
    }

    public String reasoning() {
        return reasoning.isEmpty() ? null : reasoning.toString();
    }

    public String finish() {
        String remaining = pending.toString();
        pending.setLength(0);
        if (inThink) {
            reasoning.append(remaining);
            return "";
        }
        return remaining;
    }

    private static int safePrefixLength(StringBuilder value, String delimiter) {
        int max = Math.min(value.length(), delimiter.length() - 1);
        for (int length = max; length > 0; length--) {
            if (value.substring(value.length() - length)
                    .equalsIgnoreCase(delimiter.substring(0, length))) {
                return value.length() - length;
            }
        }
        return value.length();
    }
}

为了清晰起见,示例只接受没有属性的 <think> 标签。生产实现应支持大小写无关匹配、<think > 等空白形式;遇到不完整开标签时不要急于输出,待下一个 chunk 再判定。

4. 用 Advisor 统一同步与流式路径

把响应改写放进 Controller,会让每个端点重复处理,并容易漏掉 SSE 接口。Advisor 是 Spring AI 的横切调用链,适合在模型返回后做一次统一分离。

下面的简化实现使用 response context 暂存推理文本,向下游保留已经过滤过的 AssistantMessage

java
@Component
public final class ThinkingContentAdvisor implements CallAdvisor, StreamAdvisor {
    public static final String REASONING_CONTENT = "reasoningContent";

    @Override
    public ChatClientResponse adviseCall(
            ChatClientRequest request, CallAdvisorChain chain) {
        ChatClientResponse response = chain.nextCall(request);
        AssistantMessage original = response.chatResponse().getResult().getOutput();
        ChatResult result = ThinkingExtractor.extract(original);
        return replaceAnswer(response, result.answer(), result.reasoning());
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(
            ChatClientRequest request, StreamAdvisorChain chain) {
        StreamingThinkParser parser = new StreamingThinkParser();
        return chain.nextStream(request).map(response -> {
            AssistantMessage original = response.chatResponse().getResult().getOutput();
            String answerChunk = parser.accept(original.getText());
            return replaceAnswer(response, answerChunk, parser.reasoning());
        });
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }

    @Override
    public String getName() {
        return getClass().getSimpleName();
    }
}

replaceAnswer 应用 response.mutate() 创建新响应、保留原有 generation metadata、把 AssistantMessage 的内容替换为 answer,并将 reasoningContent 从 message metadata 移除后写入 response context。不能原地修改共享响应对象。

上例中每个流式 chunk 附带当前已收集的 reasoning,是因为已发送的 SSE 事件不能在完成时倒回修改。前端若不需要显示推理,就只消费 answer;若产品确实要展示,应在协议中明确其是“模型推理摘要/过程”,且只渲染受控字段。

5. 注册、返回与记忆清洗

将 Advisor 注册为 ChatClient 默认顾问,同步和流式端点就会走同一处理链:

java
@RestController
@RequestMapping("/api/chat")
class ChatEndpoint {
    private final ChatClient chatClient;

    ChatEndpoint(ChatClient.Builder builder, ThinkingContentAdvisor advisor) {
        this.chatClient = builder.defaultAdvisors(advisor).build();
    }

    @GetMapping("/normal")
    ChatResult normal(@RequestParam String userInput) {
        ChatClientResponse response = chatClient.prompt().user(userInput)
                .call().chatClientResponse();
        return toResult(response);
    }

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    Flux<ChatResult> stream(@RequestParam String userInput) {
        return chatClient.prompt().user(userInput).stream().chatClientResponse()
                .map(this::toResult);
    }
}

private ChatResult toResult(ChatClientResponse response) {
    String answer = response.chatResponse().getResult().getOutput().getText();
    Object reasoning = response.context().get(ThinkingContentAdvisor.REASONING_CONTENT);
    return new ChatResult(answer, reasoning == null ? null : String.valueOf(reasoning));
}

此处的 context 只服务当前调用链。真正需要防护的是聊天记忆:许多记忆 Advisor 会将助手消息写到 ChatMemory,如果其内容仍含 <think>,JDBC 等持久化仓库会保存它,并在下一轮再次放入 prompt。

因此在 ChatMemory 外包一层清洗器,确保无论上游是否漏配 Advisor,写入的 assistant 消息都只保留用户可见答案:

java
public final class SanitizingChatMemory implements ChatMemory {
    private final ChatMemory delegate;

    public SanitizingChatMemory(ChatMemory delegate) {
        this.delegate = delegate;
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        delegate.add(conversationId, messages.stream()
                .map(this::sanitize)
                .toList());
    }

    private Message sanitize(Message message) {
        if (message instanceof AssistantMessage assistant) {
            return new AssistantMessage(ThinkingExtractor.extract(assistant).answer());
        }
        return message;
    }

    @Override public List<Message> get(String id) { return delegate.get(id); }
    @Override public void clear(String id) { delegate.clear(id); }
}

@Bean
ChatMemory chatMemory(JdbcChatMemoryRepository repository) {
    ChatMemory window = MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository)
            .maxMessages(10)
            .build();
    return new SanitizingChatMemory(window);
}

这里使用 JDBC 仅说明持久化记忆场景;无论底层是内存、Redis 还是其他 ChatMemoryRepository,清洗边界都应位于进入 ChatMemory 之前。

6. 最小验证集

至少为下列边界写测试,而不是只验证正常的完整 <think> 响应:

场景断言
文本带完整 <think>answer 不含标签,reasoning 包含内部文本
metadata 与文本同时存在选择 metadata,文本只用于过滤
<think> 被拆成多个 chunk前置 chunk 不泄露,结束 chunk 才输出答案
未闭合的 <think>尾部不进入 answer,作为 reasoning 处理
ChatMemory 写入 assistant 消息持久化代理只收到可见答案
无 reasoning 的普通模型响应答案原样返回,reasoning 为 null

可以用 mock ChatModel 返回固定的 AssistantMessageFlux<ChatResponse>,无需调用真实模型接口;这样既能覆盖同步、SSE 和记忆链,也不会产生 token 成本。

7. 生产落地清单

  • 为每条流创建独立解析器;不要把 parser 设成单例,否则并发请求会串内容。
  • 以 metadata 为主、文本标签为兼容回退,并删除原 message metadata 中的推理字段。
  • 将 response context 视为调用级暂存,不把它当作跨请求数据库。
  • 默认不记录原始推理;需要留存时建立独立受控存储,至少有权限、脱敏、加密和保留期。
  • ChatMemory 写入前二次清洗,阻断漏配、供应商字段变化和历史污染。
  • 流式路径禁止 block();用 Flux.mapdoFinally 等响应式操作符维持首 token 延迟和取消语义。
  • 监控解析失败、未闭合标签、reasoning 长度和清洗命中率,但日志中不要输出原始推理或完整 prompt。

参考资料