Files
HomeAgent/internal/lua/adapters/deepseek.lua
JianFeeeee f7dad243b6 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  全绿
2026-11-11 21:38:54 +08:00

240 lines
11 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

local adapter = {}
adapter.name = "deepseek"
adapter.version = "2.1.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)──
--
-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag):
-- prompt / completion / total / cache_read / cache_miss / cache_reported /
-- reasoning_tokens
-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。
--
-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。
-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时被丢掉
-- —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。
-- 丢掉之后内核只剩估算,永远答不出真实成本。
--
-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种):
-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens
-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens
local function usage_to_unified(u)
if type(u) ~= "table" then return nil end
local out = {
prompt = u.prompt_tokens or u.prompt or 0,
completion = u.completion_tokens or u.completion or 0,
total = u.total_tokens or u.total or 0
}
local details = u.prompt_tokens_details
if type(details) == "table" then
out.cache_read = details.cached_tokens or 0
-- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。
-- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。
-- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。
out.cache_reported = true
elseif (u.prompt_cache_hit_tokens or 0) > 0 then
out.cache_read = u.prompt_cache_hit_tokens
out.cache_reported = true
end
if (u.prompt_cache_miss_tokens or 0) > 0 then
out.cache_miss = u.prompt_cache_miss_tokens
end
local cd = u.completion_tokens_details
if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then
out.reasoning_tokens = cd.reasoning_tokens
end
-- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。
if out.total == 0 then out.total = out.prompt + out.completion end
return out
end
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
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.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
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage = usage_to_unified(resp.usage)
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local fn = tc["function"]
local name = tc.name
local raw_args = tc.arguments
if type(fn) == "table" then
name = fn.name or name
raw_args = fn.arguments or raw_args
end
local args = {}
if type(raw_args) == "table" then
args = raw_args
elseif type(raw_args) == "string" and raw_args ~= "" then
local args_ok, decoded = pcall(json.decode, raw_args)
if args_ok and type(decoded) == "table" then
args = decoded
elseif args_ok then
args = { value = decoded }
else
args = { raw = raw_args }
end
end
if name ~= nil and name ~= "" then
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = name,
arguments = args
})
end
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。
local usg = usage_to_unified(chunk.usage)
if not chunk.choices or #chunk.choices == 0 then
-- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。
-- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。
if usg then return json.encode({ usage = usg }) end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local unified = {
content = delta.content or "",
done = (fr ~= nil)
}
if usg then unified.usage = usg end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
-- ★ 必须处理流式 tool_calls —— 此前只透 content/done,导致 deepseek 源
-- 在**流式**模式下工具调用全部丢失,模型调不动任何工具且无任何报错。
--
-- 为什么难发现:非流式路径(transform_response)是好的,所以端到端
-- 手工测试也过;而内核的 tool call 循环默认走流式。
-- 功能判据(core 包的批内测试)直接构造 Go 结构体,绕过适配器。
--
-- 形态与 openai.lua 一致:OpenAI 兼容流式格式
-- {function:{name,arguments}, id, type, index} → homed 扁平结构
-- {id, type, name, raw_arguments, stream_index}。
if delta.tool_calls then
local tcs = {}
for _, tc in ipairs(delta.tool_calls) do
local fn = tc["function"]
local name = (type(fn) == "table" and fn.name) or tc.name or ""
local raw_args = ""
if type(fn) == "table" and type(fn.arguments) == "string" then
raw_args = fn.arguments
elseif type(tc.arguments) == "string" then
raw_args = tc.arguments
end
-- 不能按 name 过滤:流式续传片 name 为空但携带 arguments,
-- 内核 accumulateStream 按 stream_index 分桶并累积
table.insert(tcs, {
id = tc.id or "",
type = tc.type or "function",
name = name,
raw_arguments = raw_args,
-- 透传上游分片 index:并行多工具调用时内核按它区分归属桶
stream_index = tc.index or 0
})
end
unified.tool_calls = tcs
end
return json.encode(unified)
end
return adapter