fix(llm): MiMo thinking 模式 reasoning_content 多轮对话兼容修复 (#189)

MiMo V2 系列在 thinking 模式下,assistant 消息携带 tool_calls 时必须同时包含 reasoning_content,否则提供方返回 400。

- ModelFamily 新增 MIMO_THINKING 族,detect() 添加 mimo* 匹配
- FallbackPolicy 新增 XIAOMI_MIMO(patchCrossTurn=true, patchNonToolCall=true)
- 新增 ReasoningContentCache,按 tool_call_ids 回放真实推理内容
- 缓存作用范围:所有 patchCrossTurn=true 的 thinking provider(MiMo + DeepSeek)
- NodeStreamingChatHelper 流式响应完成后写入缓存

Closes #188
This commit is contained in:
倪程伟 2026-05-21 17:15:35 +08:00 committed by GitHub
parent 02407871f4
commit 7861f603eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 270 additions and 10 deletions

View File

@ -11,6 +11,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay; import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import vip.mate.llm.chatmodel.ReasoningContentCache;
import reactor.core.Disposable; import reactor.core.Disposable;
@ -1206,6 +1207,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns.
cacheReasoningContent(fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1235,6 +1240,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns. The cache replays real values instead of empty strings.
cacheReasoningContent(fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1268,6 +1277,24 @@ public class NodeStreamingChatHelper {
return builder.build(); return builder.build();
} }
/**
* Store reasoning content in the cache for cross-turn replay.
* Only caches when there are tool calls (MiMo requires reasoning_content
* specifically on assistant messages with tool_calls).
*/
private static void cacheReasoningContent(String fullThinking,
List<AssistantMessage.ToolCall> toolCalls) {
if (fullThinking == null || fullThinking.isBlank()) return;
if (toolCalls == null || toolCalls.isEmpty()) return;
List<String> ids = toolCalls.stream()
.map(AssistantMessage.ToolCall::id)
.filter(id -> id != null && !id.isEmpty())
.toList();
if (!ids.isEmpty()) {
ReasoningContentCache.store(ids, fullThinking);
}
}
/** /**
* Record token / cache usage to the optional metrics aggregator. * Record token / cache usage to the optional metrics aggregator.
* Called only from successful assembly paths ({@link #assembleResult} * Called only from successful assembly paths ({@link #assembleResult}

View File

@ -137,12 +137,17 @@ final class OpenAiRequestRewriter {
if (next != null && !next.isEmpty()) { if (next != null && !next.isEmpty()) {
injected = next; injected = next;
} else { } else {
injected = policy.emptyFallback; // For cross-turn messages, try the reasoning content cache
if (injected == null && policy.warnOnMissingReal) { // (real values from prior responses) before falling back to empty.
log.warn("[patchReasoningContent] provider={} requires real reasoning_content " injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx);
+ "but relay has no value for assistant message at index {}; " if (injected == null) {
+ "leaving null so provider returns explicit error.", injected = policy.emptyFallback;
providerIdOrUnknown(provider), i); if (injected == null && policy.warnOnMissingReal) {
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
+ "but relay has no value for assistant message at index {}; "
+ "leaving null so provider returns explicit error.",
providerIdOrUnknown(provider), i);
}
} }
} }
if (injected == null && msg.reasoningContent() == null) { if (injected == null && msg.reasoningContent() == null) {
@ -225,10 +230,11 @@ final class OpenAiRequestRewriter {
* OpenAI-compatible gateway) might still require the patch. * OpenAI-compatible gateway) might still require the patch.
*/ */
private enum FallbackPolicy { private enum FallbackPolicy {
DEEPSEEK(" ", false, true, true), DEEPSEEK (" ", false, true, true),
KIMI (" ", false, false, false), KIMI (" ", false, false, false),
OPENAI (" ", false, false, false), OPENAI (" ", false, false, false),
DEFAULT (" ", false, false, false); XIAOMI_MIMO (" ", false, true, true),
DEFAULT (" ", false, false, false);
final String emptyFallback; final String emptyFallback;
final boolean warnOnMissingReal; final boolean warnOnMissingReal;
@ -253,6 +259,7 @@ final class OpenAiRequestRewriter {
case "deepseek" -> DEEPSEEK; case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI; case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI; case "openai", "azure-openai" -> OPENAI;
case "xiaomi-mimo" -> XIAOMI_MIMO;
default -> DEFAULT; default -> DEFAULT;
}; };
} }
@ -306,6 +313,22 @@ final class OpenAiRequestRewriter {
return family.isThinking(); return family.isThinking();
} }
/**
* Look up cached reasoning content for cross-turn assistant messages.
* Returns the cached value, or {@code null} if no cache hit (caller falls
* back to the policy's empty fallback).
*/
private static String resolveCrossTurnReasoning(
OpenAiApi.ChatCompletionMessage msg, boolean isCrossTurn) {
if (!isCrossTurn) return null;
if (msg.toolCalls() == null || msg.toolCalls().isEmpty()) return null;
List<String> ids = msg.toolCalls().stream()
.map(tc -> tc.id())
.filter(id -> id != null && !id.isEmpty())
.toList();
return ReasoningContentCache.get(ids);
}
// ==================== reasoning_effort sanitizing ==================== // ==================== reasoning_effort sanitizing ====================
/** /**

View File

@ -0,0 +1,116 @@
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Static singleton cache for MiMo-style {@code reasoning_content} replay.
*
* <p>MiMo (and similar providers) require {@code reasoning_content} on assistant
* messages that carry {@code tool_calls}. When the conversation spans multiple
* turns, the cache replays the <em>real</em> reasoning content from prior
* responses instead of injecting empty strings, preserving model context.
*
* <h2>Key design</h2>
* <ul>
* <li>Key: sorted, concatenated tool_call IDs from the assistant message.
* Tool call IDs are unique per response, so this key naturally
* disambiguates across turns.</li>
* <li>TTL: 24 hours (configurable). Entries older than TTL are lazily evicted
* on access and periodically during {@link #store}.</li>
* <li>Max entries: 10,000. Oldest entries evicted when exceeded.</li>
* </ul>
*
* <h2>Usage</h2>
* <ol>
* <li><b>Store</b>: after a streaming response completes, call
* {@link #store} with the tool_call IDs and reasoning content.</li>
* <li><b>Retrieve</b>: during request patching, call {@link #get} for
* cross-turn assistant messages to fill in cached reasoning.</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
public final class ReasoningContentCache {
private static final long DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L; // 24 hours
private static final int DEFAULT_MAX_ENTRIES = 10_000;
private static final long EVICT_INTERVAL_MS = 5 * 60 * 1000L; // 5 minutes
private static final ConcurrentHashMap<String, Entry> MAP = new ConcurrentHashMap<>();
private static volatile long lastEvictMs = System.currentTimeMillis();
private ReasoningContentCache() {}
/**
* Cache reasoning content for a set of tool_call IDs.
*
* @param toolCallIds tool call IDs from the assistant message (must not be null/empty)
* @param reasoningContent the real reasoning content to cache (must not be blank)
*/
public static void store(List<String> toolCallIds, String reasoningContent) {
if (toolCallIds == null || toolCallIds.isEmpty()) return;
if (reasoningContent == null || reasoningContent.isBlank()) return;
String key = makeKey(toolCallIds);
MAP.put(key, new Entry(reasoningContent, System.currentTimeMillis()));
maybeEvict();
}
/**
* Retrieve cached reasoning content for the given tool_call IDs.
*
* @return cached reasoning content, or {@code null} if not found or expired
*/
public static String get(List<String> toolCallIds) {
if (toolCallIds == null || toolCallIds.isEmpty()) return null;
String key = makeKey(toolCallIds);
Entry entry = MAP.get(key);
if (entry == null) return null;
if (System.currentTimeMillis() - entry.storedAtMs > DEFAULT_MAX_AGE_MS) {
MAP.remove(key);
return null;
}
return entry.reasoningContent;
}
/** Clear all cached entries. */
public static void clear() {
MAP.clear();
}
/** Current cache size (for diagnostics). */
public static int size() {
return MAP.size();
}
private static String makeKey(List<String> toolCallIds) {
return String.join("|", toolCallIds.stream().sorted().toList());
}
private static void maybeEvict() {
long now = System.currentTimeMillis();
if (now - lastEvictMs < EVICT_INTERVAL_MS) return;
lastEvictMs = now;
// Remove expired entries
MAP.entrySet().removeIf(e -> now - e.getValue().storedAtMs > DEFAULT_MAX_AGE_MS);
// Remove oldest if over limit
if (MAP.size() > DEFAULT_MAX_ENTRIES) {
MAP.entrySet().stream()
.sorted((a, b) -> Long.compare(a.getValue().storedAtMs, b.getValue().storedAtMs))
.limit(MAP.size() - DEFAULT_MAX_ENTRIES)
.forEach(e -> MAP.remove(e.getKey()));
}
}
private record Entry(String reasoningContent, long storedAtMs) {}
}

View File

@ -58,6 +58,15 @@ public enum ModelFamily {
*/ */
DEEPSEEK_V4_REASONING(false, false, true, false, false, true), DEEPSEEK_V4_REASONING(false, false, true, false, false, true),
/**
* Xiaomi MiMo thinking 模型MiMo-VL-*mimo-* 系列
* <p>
* MiMo thinking 模式与 DeepSeek 类似响应返回 {@code reasoning_content}
* 后续多轮请求必须将 {@code reasoning_content} 传回否则 API 返回 400 错误
* 约束保留 max_tokens不支持 reasoning_efforttemperature/topP 用配置值
*/
MIMO_THINKING(false, false, false, false, false, true),
/** /**
* 通用 thinking 模型名称含 "thinking" "reasoner" 但不匹配上述族 * 通用 thinking 模型名称含 "thinking" "reasoner" 但不匹配上述族
* qwen3-235b-a22b-thinking-2507 * qwen3-235b-a22b-thinking-2507
@ -160,6 +169,11 @@ public enum ModelFamily {
return DEEPSEEK_REASONER; return DEEPSEEK_REASONER;
} }
// Xiaomi MiMo thinking mimo-* / MiMo-VL-* 系列
if (normalized.startsWith("mimo")) {
return MIMO_THINKING;
}
// 通用 thinking 名称含 thinking / reasoner 关键词 // 通用 thinking 名称含 thinking / reasoner 关键词
if (normalized.contains("thinking") || normalized.contains("reasoner")) { if (normalized.contains("thinking") || normalized.contains("reasoner")) {
return GENERIC_THINKING; return GENERIC_THINKING;

View File

@ -0,0 +1,69 @@
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ReasoningContentCacheTest {
@AfterEach
void cleanup() {
ReasoningContentCache.clear();
}
@Test
@DisplayName("Store and retrieve reasoning content by tool_call IDs")
void storeAndGet() {
List<String> ids = List.of("call_1", "call_2");
ReasoningContentCache.store(ids, "thinking content here");
assertEquals("thinking content here", ReasoningContentCache.get(ids));
}
@Test
@DisplayName("Cache key is order-independent (sorted tool_call IDs)")
void orderIndependent() {
ReasoningContentCache.store(List.of("call_b", "call_a"), "content");
assertEquals("content", ReasoningContentCache.get(List.of("call_a", "call_b")));
}
@Test
@DisplayName("Miss returns null")
void cacheMiss() {
assertNull(ReasoningContentCache.get(List.of("nonexistent")));
}
@Test
@DisplayName("Empty/null tool_call IDs are no-ops")
void emptyIds() {
ReasoningContentCache.store(List.of(), "content");
ReasoningContentCache.store(null, "content");
assertEquals(0, ReasoningContentCache.size());
}
@Test
@DisplayName("Blank/null reasoning content is not cached")
void blankContent() {
ReasoningContentCache.store(List.of("call_1"), "");
ReasoningContentCache.store(List.of("call_1"), " ");
ReasoningContentCache.store(List.of("call_1"), null);
assertEquals(0, ReasoningContentCache.size());
}
@Test
@DisplayName("Clear removes all entries")
void clearAll() {
ReasoningContentCache.store(List.of("call_1"), "content1");
ReasoningContentCache.store(List.of("call_2"), "content2");
assertEquals(2, ReasoningContentCache.size());
ReasoningContentCache.clear();
assertEquals(0, ReasoningContentCache.size());
assertNull(ReasoningContentCache.get(List.of("call_1")));
}
}

View File

@ -65,4 +65,15 @@ class ModelFamilyTest {
assertEquals(ModelFamily.STANDARD, ModelFamily.detect("")); assertEquals(ModelFamily.STANDARD, ModelFamily.detect(""));
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" ")); assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" "));
} }
@Test
@DisplayName("Xiaomi MiMo models → MIMO_THINKING (reasoning_content relay required)")
void mimo_thinking() {
assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("mimo-v2-flash"));
assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("MiMo-VL-7B-RL"));
assertTrue(ModelFamily.MIMO_THINKING.isThinking(),
"Mimo must be flagged as thinking so reasoning_content is patched");
assertFalse(ModelFamily.MIMO_THINKING.supportsReasoningEffort(),
"Mimo does not accept the reasoning_effort parameter");
}
} }