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 全绿
130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
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 丢了")
|
||
}
|
||
}
|