Spring AI Think 推理过程解析与存储
推理模型的响应并不总是只有最终答案。有的供应商把推理放进
reasoningContent一类 metadata,有的 OpenAI 兼容接口把它包在<think>...</think>文本里;流式输出还会把一个标签切在多个 chunk 中。若直接把原始响应返回客户端或写入聊天记忆,会出现 UI 泄露、历史污染和 token 成本失控。
本文以 Spring AI 2.0 的 ChatClient、CallAdvisor 与 StreamAdvisor 为基线,给出一个统一处理方案。示例使用通用类名,复制前请按当前 Spring AI 版本核对 API。
1. 先定边界:解析不等于长期存储
需要先把三种数据分开,否则“存储推理过程”很容易演变为把所有内部文本写进会话数据库。
| 数据 | 生命周期 | 推荐位置 | 是否回灌给下一轮模型 |
|---|---|---|---|
| 最终回答 | 对用户可见 | AssistantMessage / API 的 answer | 可以,由 ChatMemory 按窗口保存 |
| 原始推理内容 | 单次调用的临时处理数据 | ChatClientResponse.context() 或受限观测系统 | 不应默认回灌 |
| 面向用户的解释摘要 | 业务层主动生成的可见内容 | 独立响应字段或业务表 | 依产品需求决定 |
模型提供的原始 reasoning 不是可靠的审计事实,也不应被当作模型决策正确性的证明。生产系统通常应默认不持久化原始推理;若法规、排障或评测确实需要保留,应单独设计访问控制、加密、脱敏、保留期和删除流程,而不是复用 ChatMemory 表。
2. 响应来源与优先级
同一模型的不同适配器可能使用不同字段。推荐按以下顺序读取:
- 本轮响应
context中已经由上游处理器写入的推理内容。 AssistantMessage.metadata中的reasoningContent、reasoning_content、thinkingContent、thinking或reasoning。- 文本中的
<think>...</think>,作为兼容 OpenAI 协议的回退。
metadata 与文本同时存在时,应以 metadata 为准,避免将同一段推理重复拼接。无论来源是什么,最终答案都必须移除 <think> 块,避免直接透传给前端。
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. 为什么流式不能复用正则
同步响应已是完整字符串,正则可以处理;流式则可能按任意边界到达:
chunk 1: <thi
chunk 2: nk>分析中
chunk 3: </think>最终答案如果对每个 chunk 独立执行正则,前两个 chunk 都无法识别标签,<think> 及内部文本就会泄露。正确做法是为每次 HTTP 流创建一个有状态解析器,保存尚未决定归属的尾部文本,并维护 inThink 状态。
其状态机很小:
普通文本 --发现完整 <think>--> 推理文本
推理文本 --发现完整 </think>--> 普通文本
任一状态 --chunk 结尾是标签前缀--> 保留到下一个 chunk
流结束 --未闭合 think--> 将剩余文本按推理处理实现的关键不是扫描整个流,而是只保留可能组成标签的短尾部。这样既保证首 token 延迟,也不会无界缓存回答文本。
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:
@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 默认顾问,同步和流式端点就会走同一处理链:
@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 消息都只保留用户可见答案:
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 返回固定的 AssistantMessage 和 Flux<ChatResponse>,无需调用真实模型接口;这样既能覆盖同步、SSE 和记忆链,也不会产生 token 成本。
7. 生产落地清单
- 为每条流创建独立解析器;不要把 parser 设成单例,否则并发请求会串内容。
- 以 metadata 为主、文本标签为兼容回退,并删除原 message metadata 中的推理字段。
- 将 response context 视为调用级暂存,不把它当作跨请求数据库。
- 默认不记录原始推理;需要留存时建立独立受控存储,至少有权限、脱敏、加密和保留期。
- 在
ChatMemory写入前二次清洗,阻断漏配、供应商字段变化和历史污染。 - 流式路径禁止
block();用Flux.map、doFinally等响应式操作符维持首 token 延迟和取消语义。 - 监控解析失败、未闭合标签、reasoning 长度和清洗命中率,但日志中不要输出原始推理或完整 prompt。
