From 15b3c9da30cb5889a27c48395b42ec3d097c0fcf Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 12 Oct 2026 12:13:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E4=BF=AE=E5=A4=8D=20thinking=20mod?= =?UTF-8?q?e=20=E7=9A=84=20reasoning=5Fcontent=20=E5=8D=8F=E8=AE=AE=20400?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E6=80=9D=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上现象:用户发消息后助手回 「大模型错误:The `reasoning_content` in the thinking mode must be passed back to the API.」 根因:DeepSeek V4 系列(含 deepseek-flash)默认开启 thinking mode。官方协议规定, 只要请求带 tools 参数,历史里所有 assistant 消息的 reasoning_content 都必须原样回传, 否则接口直接回 HTTP 400。而 ChatMessage.toJson() 只输出 role/content/tool_calls/tool_call_id, 把该字段整个丢掉 —— 于是「第一轮模型调工具成功、第二轮请求必炸」。 改动: - ChatMessage 增加 reasoningContent 字段并在 toJson 里输出(非空才输出);ChatResult 同步解析响应字段。 - AgentOrchestrator 新增 assistantOf(),把模型每次返回的思维链写回对应的 assistant 消息 (带 tool_calls 的那条与最终回复那条都要带)。 - DeepSeekClient: * 新增 agent.llm.thinking(auto/enabled/disabled),按配置显式发送 thinking 字段; * 命中 reasoning_content 协议错误时自动关闭 thinking 重试一次(自愈,线上无需等改配置即可恢复); * humanizeApiError 增加该错误的中文话术,避免原样抛出英文协议报错。 - AgentSettings.Llm.thinking 由 boolean 改为三态字符串,默认 disabled: 本工程属工具密集编排,几乎每轮都带 tools,CoT 收益有限却显著增加延迟与 token, 且 thinking 模式下 temperature 会被忽略。 - application.yml 补 agent.llm.thinking: disabled 与说明。 新增 ThinkingModeTest(7 例):字段序列化/省略、disabled 与 auto 的请求体、响应解析、 命中协议错误时的自愈重试、以及非协议错误不被误重试。 --- .../yunhai/agent/config/AgentSettings.java | 21 +- .../com/yunhai/agent/llm/DeepSeekClient.java | 127 ++++++++-- .../com/yunhai/agent/llm/dto/ChatMessage.java | 15 ++ .../com/yunhai/agent/llm/dto/ChatResult.java | 6 + .../agent/service/AgentOrchestrator.java | 22 +- src/main/resources/application.yml | 7 + .../com/yunhai/agent/ThinkingModeTest.java | 216 ++++++++++++++++++ 7 files changed, 388 insertions(+), 26 deletions(-) create mode 100644 src/test/java/com/yunhai/agent/ThinkingModeTest.java 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 messages, JSONArray tools, String thinkingType) { + JSONObject body = buildBody(llm, messages, tools, thinkingType); String requestBody = JSON.toJSONString(body); long start = System.currentTimeMillis(); @@ -163,6 +169,13 @@ public class DeepSeekClient implements LlmClient { } else if (contentObj != null) { result.setContent(JSON.toJSONString(contentObj)); } + // ★ 思维链:thinking mode 下必须随 assistant 消息一起回传,否则下一次带 tools 的请求会 400 + Object reasoningObj = message.get("reasoning_content"); + if (reasoningObj instanceof String) { + result.setReasoningContent((String) reasoningObj); + } else if (reasoningObj != null) { + result.setReasoningContent(JSON.toJSONString(reasoningObj)); + } JSONArray toolCalls = message.getJSONArray("tool_calls"); if (toolCalls != null) { List list = new ArrayList<>(); @@ -190,12 +203,76 @@ public class DeepSeekClient implements LlmClient { result.setCompletionTokens(usage.getInteger("completion_tokens")); } - log.info("大模型调用完成 cost={}ms tools={} tokens={}/{}", + log.info("大模型调用完成 cost={}ms tools={} tokens={}/{} reasoningChars={}", cost, result.getToolCalls().size(), - result.getPromptTokens(), result.getCompletionTokens()); + result.getPromptTokens(), result.getCompletionTokens(), + StringUtils.length(result.getReasoningContent())); return result; } + /** 组装 chat/completions 请求体(thinkingType 为 null 时不发 thinking 字段,由服务端决定) */ + private JSONObject buildBody(AgentSettings.Llm llm, List messages, + JSONArray tools, String thinkingType) { + 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); + } + } + + // ★ thinking mode 显式开关(auto 时不发,交服务端默认)。 + // 必须显式按配置发送:DeepSeek V4 系列默认 enabled,不发就会带上那套「reasoning_content 必须回传」的约束。 + if (thinkingType != null) { + JSONObject thinking = new JSONObject(); + thinking.put("type", thinkingType); + body.put("thinking", thinking); + } + return body; + } + + /** + * 解析配置里的 thinking 开关: + *

+ */ + 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 同级)。 + * + *

★ 必须原样回传:官方协议规定,只要请求带 {@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 toolCalls = new ArrayList<>(); diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index 3452ee9..7292aac 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -166,14 +166,16 @@ public class AgentOrchestrator { String reply = StringUtils.isNotBlank(result.getContent()) ? result.getContent() : "(模型没有返回内容,请换个说法再试一次)"; - messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply)); + messages.add(assistantOf(reply, result.getReasoningContent())); resp.setReply(reply); finish(resp, session, totalPrompt, totalCompletion, out); return resp; } // 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法) - ChatMessage assistantMsg = new ChatMessage(ChatMessage.ROLE_ASSISTANT, result.getContent()); + // ★ 同时带上 reasoning_content:thinking mode 下带 tools 的请求要求历史 assistant 原样回传该字段, + // 否则下一轮请求会被接口判 400(The reasoning_content ... must be passed back to the API)。 + ChatMessage assistantMsg = assistantOf(result.getContent(), result.getReasoningContent()); assistantMsg.setToolCalls(result.getToolCalls()); messages.add(assistantMsg); @@ -324,6 +326,8 @@ public class AgentOrchestrator { } String idPrefix = "br_" + System.currentTimeMillis() + "_"; + // 注:这条 assistant 是「服务端补造」的(模型当轮的消息在挂起时已撤销),没有真实 reasoning_content。 + // thinking mode 关闭时(agent.llm.thinking=disabled,默认)不需要该字段;若改为开启,此处可能被接口判 400。 ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null); List calls = new ArrayList<>(); List contents = new ArrayList<>(); @@ -512,6 +516,7 @@ public class AgentOrchestrator { fn.setName(def.getName()); fn.setArguments(JSON.toJSONString(args)); tc.setFunction(fn); + // 同上:服务端补造的 assistant(无真实 reasoning_content),thinking 关闭时无需该字段 ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null); assistant.setToolCalls(Collections.singletonList(tc)); messages.add(assistant); @@ -797,6 +802,19 @@ public class AgentOrchestrator { return "「" + def.getTitle() + "」执行失败:" + (tr.getError() == null ? "未知原因" : tr.getError()); } + /** + * 构造 assistant 消息,并统一挂上 thinking mode 的思维链。 + * + *

★ 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, + * 把该字段整个丢掉了 —— 于是「第一轮调工具成功、第二轮必炸」。 + * + *

本测试锁住四件事: + *

    + *
  1. assistant 的 reasoning_content 会写进请求 JSON(且为空时不写);
  2. + *
  3. thinking 配置 disabled 时请求体显式带 {@code {"thinking":{"type":"disabled"}}};
  4. + *
  5. thinking 配置 auto 时不发该字段(交服务端决定);
  6. + *
  7. 命中 reasoning_content 协议错误时,自动关闭 thinking 重试一次(自愈)。
  8. + *
+ */ +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 oneUserMessage() { + return new ArrayList<>(Arrays.asList(ChatMessage.user("我要请假"))); + } + + private static JSONArray sampleTools() { + JSONObject fn = new JSONObject(); + fn.put("name", "leave_list"); + JSONObject tool = new JSONObject(); + tool.put("type", "function"); + tool.put("function", fn); + JSONArray arr = new JSONArray(); + arr.add(tool); + return arr; + } + + private static String okResponse(String content, String reasoning) { + JSONObject msg = new JSONObject(); + msg.put("role", "assistant"); + msg.put("content", content); + if (reasoning != null) { + msg.put("reasoning_content", reasoning); + } + JSONObject choice = new JSONObject(); + choice.put("message", msg); + choice.put("finish_reason", "stop"); + JSONArray choices = new JSONArray(); + choices.add(choice); + + JSONObject usage = new JSONObject(); + usage.put("prompt_tokens", 10); + usage.put("completion_tokens", 5); + + JSONObject o = new JSONObject(); + o.put("choices", choices); + o.put("usage", usage); + return o.toJSONString(); + } + + private static String errorResponse(String message) { + JSONObject err = new JSONObject(); + err.put("message", message); + err.put("type", "invalid_request_error"); + JSONObject o = new JSONObject(); + o.put("error", err); + return o.toJSONString(); + } + + /** 记录每次请求体、按顺序吐出预置响应,用来替换真实 HTTP 调用 */ + private static class CapturingRestTemplate extends RestTemplate { + private final List bodies = new ArrayList<>(); + private final Deque responses = new ArrayDeque<>(); + private int calls = 0; + + CapturingRestTemplate(String... presetResponses) { + for (String r : presetResponses) { + responses.add(r); + } + } + + @Override + @SuppressWarnings("unchecked") + public ResponseEntity postForEntity(String url, Object request, + Class responseType, Object... uriVariables) { + calls++; + bodies.add((String) ((HttpEntity) request).getBody()); + String body = responses.isEmpty() ? okResponse("兜底", null) : responses.poll(); + return (ResponseEntity) ResponseEntity.ok(body); + } + } +}