Files
HomeAgent/internal/agent/api/test/reasoning_protocol_test.go
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

130 lines
4.5 KiB
Go
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.

package test
// 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 都要传回,并拼进上下文。
//
// 而内核每轮都带工具定义,所以这条必然触发。修复横跨三层:
//
// L1 存(ContextEvent.ReasoningContent)+ 渲染回传
// L2 落块(RoleReasoning 独立块)
// L3 进蒸馏原文
//
// 本文件**只钉最外面那一层**:内核发给上游的 JSON 里,
// reasoning_content 是不是 assistant 消息的**顶层字段**。
//
// ## 为什么必须是顶层字段(而不是塞进 content)
//
// OpenAI 兼容格式(DeepSeek 走这条)规定:
//
// {"role":"assistant","content":"回复","reasoning_content":"思考"}
//
// 把思考拼进 content 会同时坏两件事:
// · 上游认为"没回传",照样 400
// · 模型下一轮会把自己的内心独白读成"我说过这话"
//
// 对照:Anthropic 才是 content block(thinking block)。我们走 OpenAI 兼容,
// 所以是顶层字段 —— 两者不能混。
import (
"encoding/json"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// 判据:reasoning_content 是 assistant 消息的顶层字段,与 content 平级。
//
// 变异验证(实测):把 Message.MarshalJSON 里写 reasoning_content 的那段删掉
// ⇒ 本判据红(字段消失,上游就会 400)。
func TestReasoningContentIsTopLevelField(t *testing.T) {
msg := api.Message{
Role: "assistant",
Content: "已查到:上月在 12 号出账",
ReasoningContent: "先确认问的是账单不是订单",
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
v, ok := got["reasoning_content"]
if !ok {
t.Fatalf("★ 消息里没有 reasoning_content 顶层字段(上游会 400):%s", raw)
}
if v != "先确认问的是账单不是订单" {
t.Fatalf("★ reasoning_content = %v", v)
}
// content 必须保持原样,不被思考污染。
if got["content"] != "已查到:上月在 12 号出账" {
t.Fatalf("★ content 被污染:%v", got["content"])
}
}
// 判据:无思考时**不发**该字段(不伪造空字段)。
//
// 为什么:三个层次都要区分「没有思考」与「思考是空的」。
// 无条件发空串会让每条消息都带一个无意义字段,也会让上游
// 无从判断"这一轮到底有没有思考过"。
func TestNoReasoningOmitsField(t *testing.T) {
msg := api.Message{Role: "assistant", Content: "普通回复"}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(raw), "reasoning_content") {
t.Fatalf("★ 无思考时不该出现该字段:%s", raw)
}
}
// 判据:带着 tool_calls 的 assistant 消息,reasoning_content 仍必须出现。
//
// 这条最贴近真实故障形态:DeepSeek 的触发条件是"**带 tools** 的请求历史",
// 而实际报错的具体消息正是 **assistant(tool_calls)** 那条 ——
// 模型先思考、再决定调工具,工具结果回来时那条 assistant 必须带着思考。
func TestReasoningWithToolCalls(t *testing.T) {
msg := api.Message{
Role: "assistant",
Content: "",
ReasoningContent: "需要查一下 invoice 表",
ToolCalls: []api.ToolCall{{
ID: "call_1",
Name: "sql_query",
Arguments: map[string]interface{}{"sql": "select * from invoice"},
}},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["reasoning_content"] != "需要查一下 invoice 表" {
t.Fatalf("★ 带 tool_calls 的 assistant 丢了 reasoning_content:%s", raw)
}
// content 为空的 assistant(tool_calls) 应发 null(OpenAI 语义),
// 不能变成空串 —— 有些上游对两者处理不同。
if c, ok := got["content"]; !ok || c != nil {
t.Errorf("content 期望 null,实际 %v", c)
}
if _, ok := got["tool_calls"]; !ok {
t.Error("★ tool_calls 丢了")
}
}