mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 12:17:36 +00:00
## 生产故障
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 全绿
182 lines
6.4 KiB
Go
182 lines
6.4 KiB
Go
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 里混进了思考内容")
|
||
}
|
||
}
|