diff --git a/src/main/java/com/yunhai/agent/config/AgentSettings.java b/src/main/java/com/yunhai/agent/config/AgentSettings.java index f642b2f..c08f20f 100644 --- a/src/main/java/com/yunhai/agent/config/AgentSettings.java +++ b/src/main/java/com/yunhai/agent/config/AgentSettings.java @@ -129,8 +129,25 @@ public class AgentSettings { /** 单次 HTTP 超时(毫秒)。⚠️ 实际出站超时已由平台统一客户端接管,见 {@code yh.http.read-timeout-ms};本字段保留仅作文档参考 */ private int timeout = 60000; - /** 是否开启深度思考(旧的 deepseek-reasoner 独立模型已下线;V4 系列改为在请求里带 effort 档位,本开关暂未接线) */ - private boolean thinking = false; + /** + * 深度思考(thinking mode)开关,三态:{@code auto} / {@code enabled} / {@code disabled}。 + * + *
★ 2026-10-12 必读:DeepSeek V4 系列(含 {@code deepseek-flash})默认开启 thinking mode。 + * 官方协议规定:只要请求带 {@code tools} 参数,历史里所有 assistant 消息的 + * {@code reasoning_content} 都必须原样回传,否则接口直接回 + * HTTP 400 {@code The `reasoning_content` in the thinking mode must be passed back to the API} + * —— 表现为「第一轮模型调工具成功、第二轮就报错」,非常容易误判成鉴权/网络问题。 + * + *
本工程是工具密集型编排(几乎每轮都带 tools),CoT 收益有限,却要付出 + * 更高的延迟与 token 成本;且 thinking mode 下 {@code temperature} 被忽略。 + * 因此默认 {@code disabled}(请求体显式带 {@code {"thinking":{"type":"disabled"}}})。 + * + *
{@code auto} = 不发送该字段、由服务端决定(当前服务端默认 = enabled,不推荐);
+ * {@code enabled} = 显式开启(此时 {@link com.yunhai.agent.llm.dto.ChatMessage#getReasoningContent()}
+ * 会随 assistant 消息原样回传)。注意:会话消息落库未保存该字段,
+ * 若开启 thinking,请在同一次请求内完成多轮工具循环(浏览器代执行续跑会另起请求,可能不适用)。
+ */
+ private String thinking = "disabled";
}
@Data
diff --git a/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java b/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java
index b186b85..584829e 100644
--- a/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java
+++ b/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java
@@ -44,6 +44,12 @@ public class DeepSeekClient implements LlmClient {
private static final String TOOL_CHOICE_AUTO = "auto";
+ /** thinking mode 显式开启 */
+ private static final String THINKING_ENABLED = "enabled";
+
+ /** thinking mode 显式关闭 */
+ private static final String THINKING_DISABLED = "disabled";
+
private final RestTemplate restTemplate;
private final AgentSettings settings;
@@ -88,32 +94,32 @@ public class DeepSeekClient implements LlmClient {
"未配置大模型 api-key(agent.llm.api-key)");
}
- JSONObject body = new JSONObject();
- body.put("model", llm.getModel());
- body.put("max_tokens", llm.getMaxTokens());
-
- JSONArray msgArr = new JSONArray();
- for (ChatMessage m : messages) {
- msgArr.add(m.toJson());
- }
- body.put("messages", msgArr);
-
- // 现役 V4 系列(deepseek-flash / deepseek-v4-pro)temperature 与 tools 都支持。
- // 保留 isReasoner 判断只为兼容历史上"reasoner 不支持二者"的模型名,正常情况下恒为 false。
- if (!isReasoner(llm.getModel())) {
- body.put("temperature", llm.getTemperature());
- if (tools != null && !tools.isEmpty()) {
- body.put("tools", tools);
- body.put("tool_choice", TOOL_CHOICE_AUTO);
- }
- }
-
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + llm.getApiKey());
headers.set("Accept", "application/json");
String url = StringUtils.isNotBlank(llm.getHost()) ? llm.getHost() : llm.getFallbackHost();
+ String thinkingType = thinkingType(llm);
+
+ try {
+ return doChat(url, headers, llm, messages, tools, thinkingType);
+ } catch (BizException e) {
+ // ★ 自愈:命中 thinking mode 的 reasoning_content 协议错误(HTTP 400)。
+ // 根因是历史 assistant 消息缺 reasoning_content,最省事的确定解就是关掉 thinking 重来一次。
+ // 这样即便线上配置来不及改(默认 auto/enabled),用户侧也不会卡死。
+ if (isThinkingOn(thinkingType) && isReasoningContentError(e.getMessage())) {
+ log.warn("[大模型] 命中 reasoning_content 协议错误,自动关闭 thinking 重试一次:{}", e.getMessage());
+ return doChat(url, headers, llm, messages, tools, THINKING_DISABLED);
+ }
+ throw e;
+ }
+ }
+
+ /** 实际发起一次 chat/completions 并解析响应 */
+ private ChatResult doChat(String url, HttpHeaders headers, AgentSettings.Llm llm,
+ List ★ 必须原样回传:官方协议规定,只要请求带 {@code tools} 参数,
+ * 历史里每一条 assistant 消息的 {@code reasoning_content} 都要带回(包括没调工具的那轮),
+ * 否则 HTTP 400 {@code The `reasoning_content` in the thinking mode must be passed back to the API}。
+ * 本字段由 {@link com.yunhai.agent.llm.DeepSeekClient} 从响应里解析,
+ * 由 {@code AgentOrchestrator} 写回对应 assistant 消息。
+ */
+ private String reasoningContent;
+
public ChatMessage(String role, String content) {
this.role = role;
this.content = content;
@@ -78,6 +89,10 @@ public class ChatMessage {
if (StringUtils.isNotBlank(toolCallId)) {
o.put("tool_call_id", toolCallId);
}
+ // ★ thinking mode 协议:assistant 的思维链必须原样回传,否则带 tools 的请求会 400
+ if (StringUtils.isNotBlank(reasoningContent)) {
+ o.put("reasoning_content", reasoningContent);
+ }
return o;
}
}
diff --git a/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java b/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java
index a1c349f..0523e44 100644
--- a/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java
+++ b/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java
@@ -14,6 +14,12 @@ public class ChatResult {
/** 模型回答正文(可能为空:当它只想调工具时) */
private String content;
+ /**
+ * 思维链(DeepSeek thinking mode):与 {@link #content} 同级返回。
+ * ★ 必须原样回传到后续请求的 assistant 消息,否则带 tools 的请求会 HTTP 400。
+ */
+ private String reasoningContent;
+
/** 模型要调用的工具 */
private List ★ DeepSeek V4 系列默认开启 thinking mode,官方协议要求:只要请求带 {@code tools},
+ * 历史里所有 assistant 消息的 {@code reasoning_content} 必须原样回传,否则 HTTP 400。
+ * 因此模型每一次返回的思维链都要落进对应的 assistant 消息里。
+ */
+ private ChatMessage assistantOf(String content, String reasoningContent) {
+ ChatMessage m = new ChatMessage(ChatMessage.ROLE_ASSISTANT, content);
+ m.setReasoningContent(reasoningContent);
+ return m;
+ }
+
private void finish(ChatResponse resp, SessionStore.SessionState session,
int promptTokens, int completionTokens, EventSink out) {
resp.setPromptTokens(promptTokens);
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 0b67808..32a20bd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -82,6 +82,13 @@ agent:
max-tokens: 2048
temperature: 0.3
timeout: 60000
+ # ★ 深度思考(thinking mode)开关:auto / enabled / disabled
+ # DeepSeek V4 系列(含 deepseek-flash)默认就是 enabled;一旦请求带 tools(本应用几乎每轮都带),
+ # 历史里所有 assistant 消息的 reasoning_content 必须原样回传,否则接口回 HTTP 400
+ # 「The `reasoning_content` in the thinking mode must be passed back to the API」。
+ # 本工程属工具密集编排,CoT 收益有限却显著增加延迟与 token(thinking 下 temperature 也会失效),
+ # 故默认 disabled。若确需推理能力可改 enabled(代码已按 assistant 消息回传该字段)。
+ thinking: disabled
# --- 编排参数 ---
loop:
diff --git a/src/test/java/com/yunhai/agent/ThinkingModeTest.java b/src/test/java/com/yunhai/agent/ThinkingModeTest.java
new file mode 100644
index 0000000..20aacae
--- /dev/null
+++ b/src/test/java/com/yunhai/agent/ThinkingModeTest.java
@@ -0,0 +1,216 @@
+package com.yunhai.agent;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.yunhai.agent.config.AgentSettings;
+import com.yunhai.agent.llm.DeepSeekClient;
+import com.yunhai.agent.llm.dto.ChatMessage;
+import com.yunhai.agent.llm.dto.ChatResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * thinking mode 协议回归(纯单元,不依赖 Spring)。
+ *
+ * 背景(2026-10-12 线上实测):用户发消息后助手回
+ * 「大模型错误:The `reasoning_content` in the thinking mode must be passed back to the API.」。
+ * 根因是 DeepSeek V4 系列(含 {@code deepseek-flash})默认开启 thinking mode,官方协议规定:
+ * 只要请求带 {@code tools},历史里所有 assistant 消息的 {@code reasoning_content} 都必须原样回传,
+ * 否则接口回 HTTP 400。而原先的 {@link ChatMessage#toJson()} 只输出 role/content/tool_calls,
+ * 把该字段整个丢掉了 —— 于是「第一轮调工具成功、第二轮必炸」。
+ *
+ * 本测试锁住四件事:
+ *
+ *
+ */
+ private static String thinkingType(AgentSettings.Llm llm) {
+ String t = llm.getThinking();
+ if (StringUtils.isBlank(t)) {
+ return null;
+ }
+ t = t.trim().toLowerCase();
+ if (THINKING_ENABLED.equals(t) || THINKING_DISABLED.equals(t)) {
+ return t;
+ }
+ return null;
+ }
+
+ /** 本次请求是否可能处于 thinking 开启态(enabled,或未指定而由服务端默认开启) */
+ private static boolean isThinkingOn(String thinkingType) {
+ return thinkingType == null || THINKING_ENABLED.equals(thinkingType);
+ }
+
+ /** 是否 thinking mode 的 reasoning_content 协议错误(HTTP 400,文案固定含该字段名) */
+ private static boolean isReasoningContentError(String message) {
+ return message != null && message.toLowerCase().contains("reasoning_content");
+ }
+
/**
* 把底层 HTTP 异常翻译成使用者能看懂的话。
*
@@ -256,6 +333,12 @@ public class DeepSeekClient implements LlmClient {
if (lower.contains("rate limit") || lower.contains("too many requests")) {
return "大模型请求过于频繁(" + m + "),请稍后重试";
}
+ // thinking mode 协议错误:只要请求带 tools,历史 assistant 消息的 reasoning_content 必须原样回传。
+ // 本工程已把它按 assistant 消息透传(见 ChatMessage#reasoningContent),并在命中该错误时自动关 thinking 重试。
+ if (lower.contains("reasoning_content")) {
+ return "大模型思考模式协议错误(" + m + "):历史消息未回传 reasoning_content。"
+ + "已自动降级重试;若仍失败请把 agent.llm.thinking 设为 disabled";
+ }
if (lower.contains("model not exist") || lower.contains("model_not_found")
|| lower.contains("invalid model") || lower.contains("does not exist")) {
return "大模型模型标识无效(" + m + "):请用官方实时清单 GET https://api.deepseek.com/models "
diff --git a/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java b/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java
index 26b9d5f..bfb5320 100644
--- a/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java
+++ b/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java
@@ -39,6 +39,17 @@ public class ChatMessage {
/** role=tool 时必填:对应哪一次工具调用 */
private String toolCallId;
+ /**
+ * assistant 消息的思维链(DeepSeek thinking mode 特有字段,与 content 同级)。
+ *
+ *
+ *
+ */
+class ThinkingModeTest {
+
+ /** ① assistant 的思维链必须原样出现在请求 JSON 里 */
+ @Test
+ void assistantReasoningContentIsSerializedBack() {
+ ChatMessage m = new ChatMessage(ChatMessage.ROLE_ASSISTANT, "好的");
+ m.setReasoningContent("让我先看看这个用户是谁");
+
+ JSONObject json = m.toJson();
+
+ assertEquals("assistant", json.getString("role"));
+ assertEquals("让我先看看这个用户是谁", json.getString("reasoning_content"),
+ "thinking mode 下 assistant 的 reasoning_content 必须回传");
+ }
+
+ /** ② 没有思维链时不写该字段(避免给非 thinking 场景塞脏字段) */
+ @Test
+ void blankReasoningContentIsOmitted() {
+ ChatMessage m = new ChatMessage(ChatMessage.ROLE_ASSISTANT, "好的");
+
+ assertFalse(m.toJson().containsKey("reasoning_content"), "空思维链不应输出该字段");
+ }
+
+ /** ③ 默认配置 = disabled:请求体必须显式带 thinking.type=disabled(否则服务端默认 enabled 会带出协议约束) */
+ @Test
+ void disabledThinkingIsSentExplicitly() {
+ CapturingRestTemplate rt = new CapturingRestTemplate(okResponse("你好", null));
+ DeepSeekClient client = clientWith(rt, "disabled");
+
+ client.chat(oneUserMessage(), sampleTools());
+
+ String body = rt.bodies.get(0);
+ assertTrue(body.contains("\"thinking\""), "必须显式发送 thinking 字段:" + body);
+ assertTrue(body.contains("\"disabled\""), "thinking.type 应为 disabled:" + body);
+ }
+
+ /** ④ auto:不发 thinking 字段,把决定权交回服务端 */
+ @Test
+ void autoThinkingSendsNoField() {
+ CapturingRestTemplate rt = new CapturingRestTemplate(okResponse("你好", null));
+ DeepSeekClient client = clientWith(rt, "auto");
+
+ client.chat(oneUserMessage(), sampleTools());
+
+ assertFalse(rt.bodies.get(0).contains("\"thinking\""),
+ "auto 不应发送 thinking 字段:" + rt.bodies.get(0));
+ }
+
+ /** ⑤ 响应里的 reasoning_content 要被解析出来(供编排层写回 assistant 消息) */
+ @Test
+ void reasoningContentIsParsedFromResponse() {
+ CapturingRestTemplate rt = new CapturingRestTemplate(okResponse("在的", "先确认用户身份"));
+ DeepSeekClient client = clientWith(rt, "enabled");
+
+ ChatResult result = client.chat(oneUserMessage(), sampleTools());
+
+ assertEquals("在的", result.getContent());
+ assertEquals("先确认用户身份", result.getReasoningContent());
+ }
+
+ /** ⑥ 自愈:首次命中 reasoning_content 400 → 自动关 thinking 重试一次并成功 */
+ @Test
+ void reasoningContentErrorTriggersRetryWithoutThinking() {
+ CapturingRestTemplate rt = new CapturingRestTemplate(
+ errorResponse("The `reasoning_content` in the thinking mode must be passed back to the API."),
+ okResponse("重试后的回答", null));
+ DeepSeekClient client = clientWith(rt, "enabled");
+
+ ChatResult result = client.chat(oneUserMessage(), sampleTools());
+
+ assertEquals(2, rt.calls, "应在命中协议错误后自动重试一次");
+ assertTrue(rt.bodies.get(0).contains("\"enabled\""), "首次请求按配置开启 thinking");
+ assertTrue(rt.bodies.get(1).contains("\"disabled\""), "重试时必须关闭 thinking");
+ assertEquals("重试后的回答", result.getContent());
+ }
+
+ /** ⑦ 自愈只对 reasoning_content 错误生效:别的错误不该被静默重试 */
+ @Test
+ void otherErrorsAreNotRetried() {
+ CapturingRestTemplate rt = new CapturingRestTemplate(
+ errorResponse("Insufficient Balance"));
+ DeepSeekClient client = clientWith(rt, "enabled");
+
+ Exception thrown = null;
+ try {
+ client.chat(oneUserMessage(), sampleTools());
+ } catch (Exception e) {
+ thrown = e;
+ }
+
+ assertNotNull(thrown, "余额不足应当抛出业务异常");
+ assertEquals(1, rt.calls, "非协议类错误不应重试");
+ }
+
+ /* ==================== 测试脚手架 ==================== */
+
+ private static DeepSeekClient clientWith(RestTemplate rt, String thinking) {
+ AgentSettings settings = new AgentSettings();
+ settings.getLlm().setApiKey("sk-test-key");
+ settings.getLlm().setModel("deepseek-flash");
+ settings.getLlm().setThinking(thinking);
+ return new DeepSeekClient(rt, settings);
+ }
+
+ private static List