fix(llm): apply thinking controls to deepseek-flash (#672)

This commit is contained in:
matevip 2026-10-26 06:54:13 -04:00
parent f023b6685a
commit c326e98fd2
3 changed files with 123 additions and 3 deletions

View File

@ -47,7 +47,7 @@ public enum ModelFamily {
DEEPSEEK_REASONER(false, false, false, true, true, true), DEEPSEEK_REASONER(false, false, false, true, true, true),
/** /**
* DeepSeek V4 reasoning 模型:deepseek-v4-flash / deepseek-v4-pro。 * DeepSeek reasoning 模型:deepseek-flash / deepseek-v4-flash / deepseek-v4-pro。
* <p> * <p>
* 与 {@link #DEEPSEEK_REASONER}(v3.2)的关键差异:V4 接受 {@code reasoning_effort} 字段, * 与 {@link #DEEPSEEK_REASONER}(v3.2)的关键差异:V4 接受 {@code reasoning_effort} 字段,
* 也不强制 temperature=1。OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记 * 也不强制 temperature=1。OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记
@ -158,9 +158,12 @@ public enum ModelFamily {
return KIMI_THINKING; return KIMI_THINKING;
} }
// DeepSeek V4 reasoning 族:deepseek-v4-flash / deepseek-v4-pro // deepseek-flash is the current API name; it uses the same thinking
// toggle as the legacy deepseek-v4-flash name. Missing it silently
// bypasses the decorator and leaves tool-free verification thinking on.
// 优先匹配(在 DEEPSEEK_REASONER 之前),因为两者都含 "deepseek" 前缀但 V4 接受 reasoning_effort。 // 优先匹配(在 DEEPSEEK_REASONER 之前),因为两者都含 "deepseek" 前缀但 V4 接受 reasoning_effort。
if (normalized.equals("deepseek-v4-flash") || normalized.equals("deepseek-v4-pro")) { if (normalized.equals("deepseek-flash") || normalized.equals("deepseek-v4-flash")
|| normalized.equals("deepseek-v4-pro")) {
return DEEPSEEK_V4_REASONING; return DEEPSEEK_V4_REASONING;
} }

View File

@ -0,0 +1,110 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.guard.TaskFinalizationCoordinator;
import vip.mate.agent.graph.guard.TaskFinalizationGate;
import vip.mate.agent.graph.state.ActionExecutionLedger;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Reproduces #672 when the current Flash model name bypasses thinking controls. */
class DeepSeekFlashFinalizationTransportTest {
private static final ObjectMapper JSON = new ObjectMapper();
@Test
void verifierDisablesFlashThinkingWithoutChangingRunThinkingOrReplayingFailures() throws Exception {
var requests = new CopyOnWriteArrayList<JsonNode>();
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/chat/completions", exchange -> {
JsonNode request = JSON.readTree(exchange.getRequestBody());
requests.add(request);
boolean disabled = "disabled".equals(request.path("thinking").path("type").asText());
// DeepSeek defaults to thinking enabled. Increasing max_tokens alone
// cannot ensure that a short verifier verdict will fit.
Map<String, String> delta = disabled
? Map.of("content", "{\"decision\":\"ACCEPT\",\"taskComplete\":true,\"remainingWork\":false,"
+ "\"failedCallCount\":5,\"recoveryClaims\":[],\"reason\":\"The answer accurately reports all five failures.\"}")
: Map.of("reasoning_content", "Reviewing failed operations");
String event = JSON.writeValueAsString(Map.of("id", "flash-fixture", "object", "chat.completion.chunk",
"created", 1, "model", "deepseek-flash", "choices", List.of(Map.of("index", 0,
"delta", delta, "finish_reason", disabled ? "stop" : "length"))));
byte[] body = ("data: " + event + "\n\ndata: [DONE]\n\n").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/event-stream; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) { out.write(body); }
});
server.start();
try {
var service = mock(ModelProviderService.class);
var provider = new ModelProviderEntity();
provider.setProviderId("deepseek-fixture");
provider.setRequireApiKey(false);
provider.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
when(service.isProviderConfigured(provider.getProviderId())).thenReturn(true);
when(service.readProviderGenerateKwargs(provider)).thenReturn(Map.of());
var beans = new StaticListableBeanFactory();
beans.addBean("observations", ObservationRegistry.NOOP);
var builder = new OpenAiCompatibleChatModelBuilder(service,
beans.getBeanProvider(RestClient.Builder.class), beans.getBeanProvider(WebClient.Builder.class),
beans.getBeanProvider(ObservationRegistry.class));
var config = new ModelConfigEntity();
config.setModelName("deepseek-flash");
config.setMaxTokens(8192);
var model = builder.build(config, provider, RetryTemplate.builder().maxAttempts(1).build());
var tracker = mock(ChatStreamTracker.class);
var helper = new NodeStreamingChatHelper(tracker);
var coordinator = new TaskFinalizationCoordinator(helper, tracker);
var ledger = ActionExecutionLedger.fromEvents(java.util.stream.IntStream.range(0, 5)
.mapToObj(i -> GraphEventPublisher.toolComplete("failed-" + i, "execute_code",
"Execution failed: invalid code", false)).toList());
var state = new RunState();
state.replace(MateClawStateKeys.REQUESTED_THINKING_LEVEL, "high");
ThinkingLevelHolder.set("high");
var review = coordinator.review(model, "issue-672-flash", "Report the outcomes of five failed calls",
"All five calls failed; no successful execution or recovery was observed.", ledger,
"", 16000, 0, 8, 5, true, state);
assertEquals(TaskFinalizationGate.Outcome.ACCEPT, review.result().outcome());
assertEquals(1, review.calls(), "Disabling thinking should avoid verifier budget retries");
assertEquals(1, requests.size());
var verifier = requests.get(0);
assertEquals("deepseek-flash", verifier.path("model").asText());
assertEquals("disabled", verifier.path("thinking").path("type").asText());
assertFalse(verifier.has("reasoning_effort"));
assertTrue(verifier.path("tools").isEmpty());
assertEquals(1024, verifier.path("max_tokens").asInt());
assertEquals("high", ThinkingLevelHolder.get());
assertEquals("high", state.value(MateClawStateKeys.REQUESTED_THINKING_LEVEL, String.class).orElseThrow());
model.stream(new Prompt(List.of(new UserMessage("hello")))).collectList().block();
assertEquals("enabled", requests.get(1).path("thinking").path("type").asText());
} finally {
ThinkingLevelHolder.clear();
server.stop(0);
}
}
}

View File

@ -17,6 +17,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/ */
class ModelFamilyTest { class ModelFamilyTest {
@Test
void currentDeepSeekFlashNameUsesThinkingProtocol() {
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-flash"));
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect(" DeepSeek-Flash "));
assertEquals(ModelFamily.STANDARD, ModelFamily.detect("deepseek-flash-custom"));
}
@Test @Test
@DisplayName("DeepSeek V4 (flash + pro) → DEEPSEEK_V4_REASONING with reasoning_effort enabled") @DisplayName("DeepSeek V4 (flash + pro) → DEEPSEEK_V4_REASONING with reasoning_effort enabled")
void deepSeekV4_reasoning() { void deepSeekV4_reasoning() {