Files
HomeAgent/internal/memory/coldcontext/test/reasoning_contract_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

182 lines
6.4 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)在 L2/L3 的往返判据(2026-11-11)。
//
// ## 钉的是什么
//
// 用户裁定:**L1/L2/L3 都要包含 reasoning**。此前 L2 只落 Input/Response、
// L3 蒸馏只读 Input/Response/工具输出 —— 那是错误实现。
//
// 真因(生产 400):
//
// all 2 providers failed, last error: api error 400:
// "The reasoning_content in the thinking mode must be passed back to the API."
//
// DeepSeek 规则:请求**带 tools** 时,所有历史轮的 reasoning_content 都要回传。
// 内核每轮都带工具定义 ⇒ 一旦历史里丢了思考内容,会话就永久 400。
//
// ## 判据走的是真实入口
//
// 用 ContextToColdContext(L1 → L2 的唯一入口)与 InOutWithReasoning
// (L2 → L1 的还原入口),断言"写进去的思考能不能原样读回来"。
// 不测内部函数、不碰私有字段。
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// 判据:思考内容随事件进 L2,并能从块里读回。
//
// 变异验证(实测):把 docBlocksForEntry 里生成 reasoning 块的那段删掉
// ⇒ 本判据红(reasoning 读回为空)。
func TestReasoningSurvivesL2RoundTrip(t *testing.T) {
s := newStore(t)
const thinking = "先确认用户问的是账单不是订单,再查 invoice 表"
const reply = "已查到:上月在 12 号出账"
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "上个月账单什么时候出的?",
Response: reply,
ReasoningContent: thinking,
}}, nil, nil, nil, nil)
if err != nil {
t.Fatalf("ContextToColdContext: %v", err)
}
if doc == nil {
t.Fatal("★ ContextToColdContext 返回 nil")
}
if len(doc.Events) != 1 {
t.Fatalf("★ 事件数 = %d,期望 1", len(doc.Events))
}
in, resp, reasoning, _ := doc.Events[0].InOutWithReasoning()
if reasoning != thinking {
t.Fatalf("★ L2 丢了思考内容:读回 %q,期望 %q", reasoning, thinking)
}
// 顺带钉住"没串味":三者在 L2 里必须是三个独立的东西,
// 否则回灌时会把思维链当成正式回复发给模型。
if in != "上个月账单什么时候出的?" {
t.Errorf("Input 串味:%q", in)
}
if resp != reply {
t.Errorf("Response 串味:%q", resp)
}
}
// 判据:思考块与回复块是**两个块**,不是合并成一个。
//
// 为什么单独钉:合并会让回灌时分不清哪个是 content、哪个是 reasoning,
// 而这个错误在"内容还在、只是位置不对"的形态下不会触发任何 panic。
func TestReasoningIsSeparateBlockFromResponse(t *testing.T) {
s := newStore(t)
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "问题",
Response: "回复",
ReasoningContent: "思考",
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
var reasoningBlocks, responseBlocks int
for _, b := range doc.Events[0].Blocks {
switch string(b.BlockRole) {
case string(coldcontext.TextBlockRoleReasoning):
reasoningBlocks++
case string(coldcontext.TextBlockRoleResponse):
responseBlocks++
}
}
if reasoningBlocks != 1 {
t.Errorf("★ 思考块数 = %d,期望 1(合并进 response 会让回灌串味)", reasoningBlocks)
}
if responseBlocks != 1 {
t.Errorf("★ 回复块数 = %d,期望 1", responseBlocks)
}
}
// 判据:没有思考时不得凭空造一个空块。
//
// 反向保护:如果实现改成"无条件生成 reasoning 块",上面两条仍会绿,
// 但每次归档都会多一个空块 —— L2 里堆满噪声,且空块的 ID 会随
// 时间戳变化而产生大量无效条目。
func TestNoReasoningMeansNoBlock(t *testing.T) {
s := newStore(t)
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "问题",
Response: "回复",
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
for _, b := range doc.Events[0].Blocks {
if string(b.BlockRole) == string(coldcontext.TextBlockRoleReasoning) {
t.Fatal("★ 无思考时不应生成 reasoning 块")
}
}
_, _, reasoning, _ := doc.Events[0].InOutWithReasoning()
if reasoning != "" {
t.Fatalf("★ 无思考时读回 %q,期望空串", reasoning)
}
}
// 判据:思考里带特殊字符(换行、引号、中文标点)也能原样往返。
//
// 为什么值得钉:块 ID 由 sha256(时间戳|source|role|text) 派生,
// 而 L2 落盘是 JSON。任何一处转义或截断都会让 ID 与内容不匹配,
// 表现为"块存在但读不回来"——这正是 isEntryTextBlock 的判据会失配的地方。
func TestReasoningRoundTripWithSpecialChars(t *testing.T) {
s := newStore(t)
thinking := "第一行:检查 \"invoice\" 表\n第二行:注意 NULL 值\n第三行:结论「需要重试」"
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "查一下",
Response: "好",
ReasoningContent: thinking,
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
_, _, got, _ := doc.Events[0].InOutWithReasoning()
if got != thinking {
t.Fatalf("★ 特殊字符往返失真:\n got=%q\nwant=%q", got, thinking)
}
}
// 判据:InOut 保持三返回值(不因新增 reasoning 而破坏既有调用方)。
//
// 这条是**兼容性契约**:InOut 有 8 个调用点,其中 6 个不需要思考内容。
// 如果实现图省事把 InOut 改成四返回值,那些调用点会被迫一起改 ——
// 改动量会掩盖真正的语义变化,且容易改错。
func TestInOutSignatureStillUsable(t *testing.T) {
s := newStore(t)
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "问",
Response: "答",
ReasoningContent: "思",
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
// 三返回值必须仍然可用,且语义不变。
in, resp, held := doc.Events[0].InOut()
if in != "问" || resp != "答" {
t.Fatalf("★ InOut 语义变了:in=%q resp=%q", in, resp)
}
_ = held // 无媒体块时为 nil
if strings.Contains(resp, "思") {
t.Fatal("★ InOut 的 response 里混进了思考内容")
}
}