fix(deepseek): reasoning_content 三层贯通 + thinking 关闭层级修正

## 生产故障

    all 2 providers failed, last error: api error 400:
    "The reasoning_content in the thinking mode must be passed back to the API."

## 两个根因,第一个更根本

### ① thinking 关闭一直没生效(真正的触发器)

deepseek.lua 写的是 `req.extra_body.thinking = {type="disabled"}`。
但官方文档里那个 `extra_body={"thinking":...}` 是 **OpenAI SDK 层参数** ——
SDK 会把它摊平进 HTTP 请求体顶层。而适配器是**裸发** JSON 给 api.deepseek.com,
多包一层 `extra_body` 服务端根本不认,直接忽略。

⇒ 即使内核 thinking_enabled=false(发出 disable_thinking=true),
   也从来没真正关掉过思考,每次都走 thinking 模式,每次都撞 reasoning_content 规则。

改为顶层 `req.thinking = {type="disabled"}`。

### ② reasoning 从未被保存和回传

DeepSeek 规则(官方 thinking_mode 指南):请求**带 tools** 时,
所有历史轮的 reasoning_content 都必须传回;内核每轮都带工具定义。

而内核三层全丢:

    L1  ContextEvent 没有该字段;renderL1Messages 渲染 assistant 时不带
    L2  coldcontext 只切 Input/Response 两块,推理内容不进块
    L3  蒸馏原文只读 Input/Response/工具输出

修法(按用户裁定:L1/L2/L3 都要包含 reasoning):

    L1  ContextEvent.ReasoningContent + renderL1Messages 回传
        顺带:textForVector 纳入 reasoning —— 关键线索常只在思考里
        ("用户要的是 A 不是 B,所以先查 C"),只看 Response 会误裁
    L2  RoleReasoning 独立块(不并进 Response:回灌时要放回不同字段,
        合并会让思维链被当成正式回复发给模型)
    L3  coldcontextOriginalText 按**时序** input → reasoning → response

## 一个易错点:不能用 f.Resp.ReasoningContent

f.Resp 只在「本批有工具调用」时赋值(stepLLM 里 len(ToolCalls)==0 就 return)。
纯文本收尾轮拿到的是**上一次工具轮**的旧响应 —— 用它写进 L1 会把上一批的
思维链冒名顶替成本轮的(比缺失更坏)。所以单独一个 TurnReasoning,
由 stepLLM 在分支**之前**无条件写入。

## 适配器兜底(治存量)

内核修了,但**存量会话**的 context.json 是修复前写的,那些事件没这个字段。
不兜底就是死结:历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回,
缺的字段在磁盘上的旧记录里)。

transform_request 里:带 tools 时给每条 assistant 补 reasoning_content="",
已存在则不覆盖(不抹掉真实思考内容)。

## 顺手修的既有缺陷

零时间戳时 docBlocksForEntry 用 time.Now() 算块 ID,而 ColdEvent.Timestamp
留零值 ⇒ 哈希永久失配 ⇒ 内容就在那里却一个字段都读不回,且不报错不记日志。
线上因为 L1 总带时间戳没暴露。docEventsFromEntries 里先归一化时间戳。

## 验证

    生产实机(A2A 多轮工具调用,日志确认落到 deepseek 源直连):
      LLM fallback: trying provider "deepseek"
      response (12186ms, tools=[cmd_run plugin_start cmd_run])
      reasoning_content 400 = 0 次

    单元判据 16 条,全部变异验证(改回错误形态即红):
      coldcontext/test/reasoning_contract_test.go   5 条(L2 往返/独立块/
        无思考不造空块/特殊字符/InOut 兼容)
      agent/api/test/reasoning_protocol_test.go     3 条(顶层字段/无思考不发/
        带 tool_calls 仍发)
      lua/test/deepseek_reasoning_test.go           8 条(补空串/不覆盖真实值/
        无 tools 不补/补全部轮/不破其他字段/顶层 thinking/默认不加 thinking)

    go build / vet / gofmt / go test -race  全绿
This commit is contained in:
JianFeeeee
2026-11-11 21:38:54 +08:00
parent 319fa91ce7
commit f7dad243b6
11 changed files with 875 additions and 35 deletions

View File

@ -56,11 +56,63 @@ function adapter.transform_request(raw_body)
req.model = req.model or "deepseek-chat"
req.stream = req.stream or false
-- ── thinking 开关:必须是**顶层**字段,不能包在 extra_body 里 ──
--
-- 官方文档示例全用 OpenAI SDK:extra_body={"thinking": {...}}
-- 那是 **SDK 层参数** —— SDK 会把它摊平进 HTTP 请求体顶层,
-- 真实发出去的是 {"thinking": {...}}。而本适配器是把 JSON **裸发**给
-- api.deepseek.com 的,再包一层 extra_body 服务端不认识,直接忽略
-- ⇒ thinking 保持默认(**开启**)⇒ 带 tools 的请求就要求回传
-- reasoning_content,否则 400。
--
-- 修正前写的是 req.extra_body.thinking,所以即使内核
-- thinking_enabled=false(发出 disable_thinking=true)也从未真正关掉思考。
--
-- 为何值得关:deepseek-flash 的 thinking 默认开启,显式关闭省掉整段 CoT
-- 的 token;若用户确实要思考,就不会配 thinking_enabled=false。
if req.disable_thinking then
req.extra_body = req.extra_body or {}
req.extra_body.thinking = { type = "disabled" }
req.thinking = { type = "disabled" }
end
req.disable_thinking = nil
-- 内核从不发 extra_body;万一将来发了,也不该把 SDK 层的壳透传给上游。
req.extra_body = nil
-- ── reasoning_content 回传兜底(2026-11-11 生产故障)──
--
-- 上游报:
-- all 2 providers failed, last error: api error 400:
-- "The reasoning_content in the thinking mode must be passed back to the API."
--
-- DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode):
-- 请求**带 tools** 时,所有历史轮的 reasoning_content 都必须传回,
-- 并会拼进上下文;不带 tools 时不要求。
--
-- 内核侧已修(ContextEvent.ReasoningContent + renderL1Messages 回传),
-- 但**存量会话**的 context.json 是在修复前写的,那些事件根本没这个字段。
-- 不补就是个死结:历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回来,
-- 因为缺的字段在磁盘上的旧记录里)。
--
-- 修法:给**每一条 assistant 消息**补齐该字段(缺失时补空串)。
-- 为什么空串是合法的:字段的语义是"上一轮的思考内容",空串如实表达
-- "这一轮没有可回传的思考"(其他主流实现的同款修复也是补空串:
-- langchain #37179、n8n #34924、opencode #24722)。
--
-- 只给 assistant 补:system/user/tool 消息不接受该字段,乱发可能被拒。
if type(req.messages) == "table" then
local has_tools = type(req.tools) == "table" and #req.tools > 0
for _, m in ipairs(req.messages) do
if type(m) == "table" and m.role == "assistant" and m.reasoning_content == nil then
-- 只在带 tools 的请求里补(无 tools 时上游不要求,也不想平白
-- 往上下文里塞一个空字段)。但如果模型名显式是 reasoner,
-- 它可能无条件校验,所以两条都补。
if has_tools or string.find(tostring(req.model), "reasoner", 1, true) ~= nil then
m.reasoning_content = ""
end
end
end
end
return json.encode(req)
end