mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 12:17:36 +00:00
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 全绿
This commit is contained in:
129
internal/agent/api/test/reasoning_protocol_test.go
Normal file
129
internal/agent/api/test/reasoning_protocol_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
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 丢了")
|
||||
}
|
||||
}
|
||||
@ -67,13 +67,41 @@ type ContextEvent struct {
|
||||
//
|
||||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
||||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
||||
ID string `json:"id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Input string `json:"input"`
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Input string `json:"input"`
|
||||
Response string `json:"response,omitempty"`
|
||||
|
||||
// ReasoningContent 是本轮 assistant 的思考内容(thinking 模式的思维链)。
|
||||
//
|
||||
// ## 为什么必须存并必须回传(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):
|
||||
//
|
||||
// 后续请求是否要回传 reasoning_content,取决于请求是否带 tools:
|
||||
// 带 tools ⇒ **所有历史轮的 reasoning_content 都要传回**,并会拼进上下文
|
||||
//
|
||||
// 而内核**每一轮都带 tools**(工具定义常驻),所以这条必触发。
|
||||
//
|
||||
// 此前这里没有这个字段:resp.ReasoningContent 收到了(只用于发事件、
|
||||
// 显示思考过程),但**没进 L1**,下一轮 renderL1Messages 自然带不上
|
||||
// ⇒ 只要对话里出现过一次工具调用 + 思考,整个会话就永久 400
|
||||
// (上游多家 agent 项目都踩了同一个坑:langchain #37179、n8n #34924、
|
||||
// opencode #24722、codex #24500)。
|
||||
//
|
||||
// 语义边界:这是**协议字段**,不是记忆内容 ——
|
||||
// · 不向量化、不进 L2/L3 蒸馏(思维链不是事实)
|
||||
// · 只在渲染 provider 消息时回填
|
||||
// · omitempty:存量 context.json 读回来为空串,不伪造
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
|
||||
// ToolCalls 是本次事件里**每一次**工具调用的完整记录(含参数与配对键)。
|
||||
//
|
||||
@ -833,6 +861,34 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
|
||||
text = evt.Input
|
||||
}
|
||||
|
||||
// ★ 思维链参与 L1 相关性打分(2026-11-11 用户裁定)。
|
||||
//
|
||||
// 为何应当包含:L1 裁剪是拿**当前输入**给每条历史事件打分,决定哪些
|
||||
// 留在窗口里。而关键线索常只在思考里:
|
||||
//
|
||||
// 用户其实要的是 A 不是 B ⇒ 所以先查 C ⇒ (回复里只说"已查到")。
|
||||
//
|
||||
// 只看 Response 会把这轮判成"与当前输入无关"而剔掉,下一轮就得重查。
|
||||
// 思考内容正是“为什么这条历史相关”的真实依据。
|
||||
//
|
||||
// ★ 边界(为何这与“思维链不进长期记忆”不矛盾):
|
||||
//
|
||||
// L1 向量 = 本轮窗口内的取舍打分(临时坐标,Prune 后重算)
|
||||
// L2/L3 = 长期记忆(coldcontext 只取 Input/Response,见 coldcontext 注释)
|
||||
//
|
||||
// 所以思维链可以参与前者,但**不**进后者。冷化时它随事件一起
|
||||
// 离开 L1,不会被制成文本块、不被蒸馏成三元组。
|
||||
//
|
||||
// 放长在 Cleaner **之前**:与 Response 同路也过清理(同是模型产出,
|
||||
// 没有理由一个洗一个不洗)。
|
||||
if evt.ReasoningContent != "" {
|
||||
if text == "" {
|
||||
text = evt.ReasoningContent
|
||||
} else {
|
||||
text = text + "\n" + evt.ReasoningContent
|
||||
}
|
||||
}
|
||||
|
||||
// 计算层:应用输入通道的 Cleaner(不改原文,仅在计算层清洗)
|
||||
if channelDefLookup != nil {
|
||||
if chDef, ok := channelDefLookup(evt.Source); ok && chDef.Cleaner != nil {
|
||||
@ -1192,13 +1248,16 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, col
|
||||
entries := make([]coldcontext.ContextEntry, len(archive))
|
||||
for i, s := range archive {
|
||||
entries[i] = coldcontext.ContextEntry{
|
||||
Timestamp: s.event.Timestamp,
|
||||
Source: s.event.Source,
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
|
||||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||||
Timestamp: s.event.Timestamp,
|
||||
Source: s.event.Source,
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
// ★ 思考内容必须随事件进 L2(2026-11-11 用户裁定)。
|
||||
// 漏了它,事件一旦被裁下,思维链就永久丢失。
|
||||
ReasoningContent: s.event.ReasoningContent,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
|
||||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||||
}
|
||||
}
|
||||
doc, err := coldcontextStore.ContextToColdContext("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
|
||||
@ -612,7 +612,12 @@ func isTemplateSummary(s string) bool {
|
||||
return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话")
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
// reasoning 是本轮模型的思考内容(thinking 模式)。空串表示无思考。
|
||||
//
|
||||
// ★ 为何是参数而不是从 a 上读字段:本轮思考属于**这个 TaskFrame**
|
||||
// (f.TurnReasoning),不是 agent 的全局状态。agent 级字段在并发任务下
|
||||
// 会互相覆盖 —— 而记忆候选一旦写错就是持久化的错。
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response, reasoning string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
payload := map[string]interface{}{
|
||||
"source": source,
|
||||
"input": input,
|
||||
@ -622,6 +627,15 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
||||
"agent_id": string(a.id),
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
// ★ 思考内容也要进记忆候选(2026-11-11 用户裁定:L2/L3 都包含 reasoning)。
|
||||
//
|
||||
// memory_candidate 是**不经 context** 的另一条持久化侧路,所以它不会
|
||||
// 从 ContextEvent 那里自动带上 reasoning —— 必须显式放进去。
|
||||
// 漏了它,走这条路的消费方拿到的记忆就只有"做了什么",没有"为何"。
|
||||
// 空串不放:不向消费方伪造一个空字段。
|
||||
if reasoning != "" {
|
||||
payload["reasoning_content"] = reasoning
|
||||
}
|
||||
// ★ 记忆候选是**不经 context** 的另一条持久化侧路(走 EmitOutput)。
|
||||
// 不在这里拦,明文会直接进长期记忆 —— 而记忆跨会话保留、每轮被召回。
|
||||
a.scrubMemoryCandidate(payload)
|
||||
@ -657,11 +671,22 @@ func coldcontextOriginalText(coldDoc *coldcontext.ColdDoc) string {
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, ev := range coldDoc.Events {
|
||||
in, resp, _ := ev.InOut()
|
||||
// ★ 思考内容必须进蒸馏原文(2026-11-11 用户裁定)。
|
||||
//
|
||||
// 只取 Input/Response 会丢掉决策依据:"排除掉了 B,因为 B 的接口
|
||||
// 不返回时间戳"这类判断就只在思考里。丢掉它,蒸馏出的三元组就只剩
|
||||
// "选了 A",没有"为何不是 B"——而后者往往才是可复用的知识。
|
||||
//
|
||||
// 顺序按**时序**:输入 → 思考 → 回复。思考发生在回复之前。
|
||||
in, resp, reasoning, _ := ev.InOutWithReasoning()
|
||||
if in != "" {
|
||||
b.WriteString(in)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if reasoning != "" {
|
||||
b.WriteString(reasoning)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if resp != "" {
|
||||
b.WriteString(resp)
|
||||
b.WriteString("\n")
|
||||
|
||||
@ -672,7 +672,21 @@ func (a *Agent) renderL1Messages(maxTokens int) []agentAPI.Message {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
|
||||
}
|
||||
if e.Response != "" || len(calls) > 0 {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: e.Response, ToolCalls: calls})
|
||||
// ★ ReasoningContent 必须跟着 assistant 消息一起回传(2026-11-11 生产故障)。
|
||||
//
|
||||
// DeepSeek thinking 模式:请求**带 tools** 时,所有历史轮的
|
||||
// reasoning_content 都必须传回,否则 400(官方指南 thinking_mode)。
|
||||
// 内核每轮都带工具定义,所以这里漏一个字段就会让会话永久不可用。
|
||||
//
|
||||
// omitempty 在 agentAPI.Message 上:空串不发该字段 ——
|
||||
// 存量事件(本次修复前写入的 context.json)自然无该字段,
|
||||
// 那种情况交给适配器兜底(见 deepseek.lua 的 transform_request)。
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "assistant",
|
||||
Content: e.Response,
|
||||
ReasoningContent: e.ReasoningContent,
|
||||
ToolCalls: calls,
|
||||
})
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
if tr.CallID != "" && !activeCallIDs[tr.CallID] {
|
||||
|
||||
@ -180,6 +180,18 @@ type TaskFrame struct {
|
||||
CurRaw interface{}
|
||||
Resp *agentAPI.CompletionResponse
|
||||
|
||||
// TurnReasoning 是**本次任务里最后一次** LLM 响应的思考内容。
|
||||
//
|
||||
// ★ 为何不能靠 f.Resp.ReasoningContent:
|
||||
//
|
||||
// f.Resp 只在“本批有工具调用”时才赋值(stepLLM 里 len(resp.ToolCalls)==0
|
||||
// 就 return 了)。纯文本收尾的那一轮 f.Resp 要么是 nil,要么还指着
|
||||
// **上一次工具轮**的旧响应 —— 拿它写进 L1 会把上一批的思维链
|
||||
// 冒名顶替成本轮的(比缺失更坏:模型会看到张冠李戴的推理)。
|
||||
//
|
||||
// 因此单独一个字段,由 stepLLM **无条件**写入(在分支之前)。
|
||||
TurnReasoning string
|
||||
|
||||
// Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明),
|
||||
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
|
||||
// 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。
|
||||
@ -596,14 +608,18 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
//
|
||||
// 声明来源:payload["no_memory"](插件注入)或 ChannelDef.NoMemory(通道)。
|
||||
turnEvt := ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
NoMemory: f.StageCtx.NoMemory,
|
||||
Input: f.CleanInput,
|
||||
Response: f.Response,
|
||||
ToolsUsed: f.ToolsUsed,
|
||||
ToolResults: f.ToolResults,
|
||||
ToolCalls: f.ToolCalls,
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
NoMemory: f.StageCtx.NoMemory,
|
||||
Input: f.CleanInput,
|
||||
Response: f.Response,
|
||||
// ★ 思维链必须进 L1:下一轮 renderL1Messages 要把它原样回传给上游。
|
||||
// DeepSeek thinking 模式在**带 tools 的请求**里强制要求历史 reasoning_content,
|
||||
// 而内核每轮都带工具定义 ⇒ 不存就永久 400。详见字段注释。
|
||||
ReasoningContent: f.TurnReasoning,
|
||||
ToolsUsed: f.ToolsUsed,
|
||||
ToolResults: f.ToolResults,
|
||||
ToolCalls: f.ToolCalls,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
|
||||
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append),
|
||||
@ -613,7 +629,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
a.emitResponse(evt, f.Response, f.turnUsage)
|
||||
|
||||
if !f.StageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed)
|
||||
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.TurnReasoning, f.ToolResults, f.ToolsUsed)
|
||||
}
|
||||
f.Terminal = terminalOK
|
||||
}
|
||||
@ -910,6 +926,11 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 必须放在分支**之前**:下一行的 len(ToolCalls)==0 会直接 return,
|
||||
// 那条路径也要记下 reasoning(否则纯文本收尾轮的思维链丢失,而它同样
|
||||
// 属于“带 tools 的请求历史”,DeepSeek 照样要它)。
|
||||
f.TurnReasoning = resp.ReasoningContent
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
f.Response = resp.Content
|
||||
return outcomeDone
|
||||
|
||||
@ -1464,12 +1464,16 @@ func (a *Agent) restoreDocEvents(d *coldcontext.ColdDoc, inactivePages []int) in
|
||||
|
||||
n, blocks := 0, 0
|
||||
for _, ev := range d.Events {
|
||||
input, response, held := ev.InOut()
|
||||
// ★ 用 InOutWithReasoning:回灌要完整还原事件,包括思考内容。
|
||||
// 只用 InOut 会让回填出的 ContextEvent 缺 ReasoningContent,
|
||||
// 于是再次被裁下时思维链就永久丢了(而且 L1 里那份已经是唯一副本)。
|
||||
input, response, reasoning, held := ev.InOutWithReasoning()
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
|
||||
Source: ev.Source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
|
||||
Source: ev.Source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ReasoningContent: reasoning,
|
||||
// ★ 回填事件**不参与记忆计算**,但**照常参与归档**。
|
||||
//
|
||||
// 为什么要标记:它们的内容刚在 L2 里被向量化过一次,
|
||||
|
||||
@ -56,11 +56,63 @@ function adapter.transform_request(raw_body)
|
||||
|
||||
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.extra_body = req.extra_body or {}
|
||||
req.extra_body.thinking = { type = "disabled" }
|
||||
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
|
||||
|
||||
|
||||
261
internal/lua/test/deepseek_reasoning_test.go
Normal file
261
internal/lua/test/deepseek_reasoning_test.go
Normal file
@ -0,0 +1,261 @@
|
||||
package test
|
||||
|
||||
// deepseek 适配器的 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 规则:请求带 tools 时,所有历史轮的 reasoning_content 都要传回。
|
||||
//
|
||||
// 内核侧三层已修(L1 存+回传、L2 落块、L3 进蒸馏),但**存量会话**的
|
||||
// context.json 是修复前写的,那些历史事件根本没有该字段。不兜底就是死结:
|
||||
// 历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回来,缺的字段在磁盘上)。
|
||||
//
|
||||
// 所以适配器必须补字段。本文件钉住"兜底补得对、且不覆盖真实值"。
|
||||
//
|
||||
// ## 为什么要补**空串**而不是跳过
|
||||
//
|
||||
// 字段语义是"上一轮的思考内容"。空串如实表达"这一轮没有可回传的思考"。
|
||||
// 其他主流实现的同款修复也是补空串(langchain #37179、n8n #34924、
|
||||
// opencode #24722)—— 上游接受,且比删掉整条消息更保真。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/lua"
|
||||
)
|
||||
|
||||
// newStartedVM 起一个加载了内置适配器的 VM(与运行时同一套落盘逻辑)。
|
||||
func newStartedVM(t *testing.T) *lua.VM {
|
||||
t.Helper()
|
||||
v := lua.NewVM(t.TempDir())
|
||||
if err := v.Start(); err != nil {
|
||||
t.Fatalf("VM.Start: %v", err)
|
||||
}
|
||||
t.Cleanup(v.Stop)
|
||||
return v
|
||||
}
|
||||
|
||||
// transform 调真实内置适配器的 transform_request,返回解码后的请求体。
|
||||
func transform(t *testing.T, v *lua.VM, body string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := v.CallTransformRequest("deepseek", body)
|
||||
if err != nil {
|
||||
t.Fatalf("CallTransformRequest: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("适配器输出不是合法 JSON: %v\n%s", err, out)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// assistantMsgs 取请求里所有 assistant 消息。
|
||||
func assistantMsgs(req map[string]interface{}) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
msgs, _ := req["messages"].([]interface{})
|
||||
for _, m := range msgs {
|
||||
if mm, ok := m.(map[string]interface{}); ok && mm["role"] == "assistant" {
|
||||
out = append(out, mm)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 判据:带 tools + assistant 缺 reasoning_content ⇒ 必须补上空串。
|
||||
//
|
||||
// 变异验证(实测):把 transform_request 里补字段那段删掉 ⇒ 本判据红。
|
||||
func TestAdapterBackfillsReasoningWhenToolsPresent(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
// 存量会话形态:assistant(tool_calls) 没有 reasoning_content
|
||||
body := `{
|
||||
"model":"deepseek-chat",
|
||||
"tools":[{"type":"function","function":{"name":"sql_query","parameters":{"type":"object"}}}],
|
||||
"messages":[
|
||||
{"role":"user","content":"上个月账单什么时候出的?"},
|
||||
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"sql_query","arguments":"{}"}}]},
|
||||
{"role":"tool","tool_call_id":"c1","content":"12号"}
|
||||
]
|
||||
}`
|
||||
|
||||
got := transform(t, v, body)
|
||||
as := assistantMsgs(got)
|
||||
if len(as) != 1 {
|
||||
t.Fatalf("★ assistant 消息数 = %d,期望 1", len(as))
|
||||
}
|
||||
if _, ok := as[0]["reasoning_content"]; !ok {
|
||||
t.Fatalf("★ 带 tools 的请求里 assistant 缺 reasoning_content(上游会 400):%v", as[0])
|
||||
}
|
||||
// 补的是空串,不是凭空造内容
|
||||
if s, _ := as[0]["reasoning_content"].(string); s != "" {
|
||||
t.Errorf("★ 兜底应补空串,实际 %q(不得伪造思考内容)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:**不能覆盖**内核已经带来的真实思考内容。
|
||||
//
|
||||
// 这条比上面那条更要紧:覆盖 = 把真实思维链换成空串,
|
||||
// 上游虽然不报 400 了,但多轮上下文里的推理链条被抹掉,
|
||||
// 属于"错误从显式变隐式"。
|
||||
func TestAdapterDoesNotOverwriteRealReasoning(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
const realThinking = "先确认问的是账单不是订单"
|
||||
body := `{
|
||||
"model":"deepseek-chat",
|
||||
"tools":[{"type":"function","function":{"name":"sql_query","parameters":{"type":"object"}}}],
|
||||
"messages":[
|
||||
{"role":"user","content":"问"},
|
||||
{"role":"assistant","content":"答","reasoning_content":"` + realThinking + `"}
|
||||
]
|
||||
}`
|
||||
|
||||
got := transform(t, v, body)
|
||||
as := assistantMsgs(got)
|
||||
if len(as) != 1 {
|
||||
t.Fatalf("assistant 数 = %d", len(as))
|
||||
}
|
||||
if s, _ := as[0]["reasoning_content"].(string); s != realThinking {
|
||||
t.Fatalf("★ 真实思考被覆盖:%q(期望 %q)", s, realThinking)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:不带 tools 时不补(上游不要求,也不必给上下文塞空字段)。
|
||||
func TestAdapterLeavesReasoningAloneWithoutTools(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
body := `{
|
||||
"model":"deepseek-chat",
|
||||
"messages":[
|
||||
{"role":"user","content":"你好"},
|
||||
{"role":"assistant","content":"你好,有什么可以帮你"}
|
||||
]
|
||||
}`
|
||||
|
||||
got := transform(t, v, body)
|
||||
as := assistantMsgs(got)
|
||||
if len(as) != 1 {
|
||||
t.Fatalf("assistant 数 = %d", len(as))
|
||||
}
|
||||
if _, ok := as[0]["reasoning_content"]; ok {
|
||||
t.Fatalf("★ 无 tools 时不该补该字段:%v", as[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:多轮历史里**每一条** assistant 都要有,不能只补最后一条。
|
||||
//
|
||||
// DeepSeek 的要求是"所有历史轮"。只补最后一条会让第二次工具回环
|
||||
// 再次 400 —— 而那种"修一次好一次、第三轮又坏"最难查。
|
||||
func TestAdapterBackfillsEveryAssistantTurn(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
body := `{
|
||||
"model":"deepseek-chat",
|
||||
"tools":[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}],
|
||||
"messages":[
|
||||
{"role":"user","content":"a"},
|
||||
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"t","arguments":"{}"}}]},
|
||||
{"role":"tool","tool_call_id":"c1","content":"r1"},
|
||||
{"role":"user","content":"b"},
|
||||
{"role":"assistant","content":"","tool_calls":[{"id":"c2","type":"function","function":{"name":"t","arguments":"{}"}}]},
|
||||
{"role":"tool","tool_call_id":"c2","content":"r2"}
|
||||
]
|
||||
}`
|
||||
|
||||
got := transform(t, v, body)
|
||||
as := assistantMsgs(got)
|
||||
if len(as) != 2 {
|
||||
t.Fatalf("assistant 数 = %d,期望 2", len(as))
|
||||
}
|
||||
for i, m := range as {
|
||||
if _, ok := m["reasoning_content"]; !ok {
|
||||
t.Errorf("★ 第 %d 条 assistant 缺 reasoning_content(要补全部历史轮)", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:适配器不能顺手弄坏别的字段(tools / messages / model)。
|
||||
//
|
||||
// 反向保护:兜底逻辑若写成"重建整个请求体",很容易漏字段。
|
||||
// 这条钉住"只补一个字段,其余原样"。
|
||||
func TestAdapterPreservesOtherFields(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
body := `{
|
||||
"model":"deepseek-chat",
|
||||
"temperature":0.7,
|
||||
"max_tokens":1024,
|
||||
"stream":true,
|
||||
"tools":[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}],
|
||||
"messages":[{"role":"user","content":"hi"}]
|
||||
}`
|
||||
|
||||
got := transform(t, v, body)
|
||||
if got["model"] != "deepseek-chat" {
|
||||
t.Errorf("★ model 被改:%v", got["model"])
|
||||
}
|
||||
if temp, _ := got["temperature"].(float64); temp != 0.7 {
|
||||
t.Errorf("★ temperature 被改:%v", got["temperature"])
|
||||
}
|
||||
if mt, _ := got["max_tokens"].(float64); mt != 1024 {
|
||||
t.Errorf("★ max_tokens 被改:%v", got["max_tokens"])
|
||||
}
|
||||
if got["stream"] != true {
|
||||
t.Errorf("★ stream 被改:%v", got["stream"])
|
||||
}
|
||||
if _, ok := got["tools"]; !ok {
|
||||
t.Error("★ tools 丢了")
|
||||
}
|
||||
// 无思考内容的 user 消息不该被加上该字段
|
||||
msgs, _ := got["messages"].([]interface{})
|
||||
for _, m := range msgs {
|
||||
if mm, ok := m.(map[string]interface{}); ok && mm["role"] == "user" {
|
||||
if _, has := mm["reasoning_content"]; has {
|
||||
t.Errorf("★ 给 user 消息加了 reasoning_content(只该给 assistant)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:disable_thinking 必须转成**顶层** thinking 字段。
|
||||
//
|
||||
// ## 为什么这条最关键(它是 400 的第一根因)
|
||||
//
|
||||
// 官方文档示例全用 OpenAI SDK:extra_body={"thinking": {...}}。
|
||||
// 那是 **SDK 层参数** —— SDK 会摊平进请求体顶层。而适配器是裸发 HTTP,
|
||||
// 修正前写成 req.extra_body.thinking,服务端不认识这个包,直接忽略
|
||||
// ⇒ thinking 保持默认(开启)⇒ 带 tools 的请求强制要求 reasoning_content
|
||||
// ⇒ 400。也就是说:配置 thinking_enabled=false 也从未真正关掉过思考。
|
||||
//
|
||||
// 变异验证(实测):把 req.thinking 改回 req.extra_body.thinking ⇒ 本判据红。
|
||||
func TestAdapterDisablesThinkingAtTopLevel(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
got := transform(t, v, `{"model":"deepseek-chat","disable_thinking":true,"messages":[{"role":"user","content":"x"}]}`)
|
||||
|
||||
if _, ok := got["disable_thinking"]; ok {
|
||||
t.Error("★ disable_thinking 应被消费掉,不该透传给上游")
|
||||
}
|
||||
// 必须是顶层
|
||||
th, ok := got["thinking"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("★ 缺少顶层 thinking 字段(上游会忽略 ⇒ 思考关不掉):%v", got)
|
||||
}
|
||||
if th["type"] != "disabled" {
|
||||
t.Fatalf("★ thinking.type = %v,期望 disabled", th["type"])
|
||||
}
|
||||
// 不得留 extra_body 壳(SDK 层的参数名,裸 HTTP 上游不认识)
|
||||
if _, ok := got["extra_body"]; ok {
|
||||
t.Error("★ 不应把 extra_body 壳透传给上游(SDK 层参数)")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:不带 disable_thinking 时,不得凭空加 thinking 字段。
|
||||
//
|
||||
// 反向保护:默认不改上游行为。上游默认是**开启**思考,
|
||||
// 我们只在用户明确要求关闭时才动。
|
||||
func TestAdapterNoThinkingFieldByDefault(t *testing.T) {
|
||||
v := newStartedVM(t)
|
||||
got := transform(t, v, `{"model":"deepseek-chat","messages":[{"role":"user","content":"x"}]}`)
|
||||
if _, ok := got["thinking"]; ok {
|
||||
t.Fatalf("★ 未要求关闭时不该加 thinking:%v", got["thinking"])
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,28 @@ const (
|
||||
RoleRequest BlockRole = "request"
|
||||
// RoleResponse 是页的响应端:llm 的 output,或 tool 的 result。
|
||||
RoleResponse BlockRole = "response"
|
||||
// RoleReasoning 是 llm_round 页里的**思考内容**(thinking 模式的思维链)。
|
||||
//
|
||||
// ## 为何它必须是独立一块,而不是拼进 RoleResponse
|
||||
//
|
||||
// 两者在协议层是**不同的字段**:
|
||||
//
|
||||
// assistant.content ← 模型交付的回复
|
||||
// assistant.reasoning_content ← 模型的思考过程
|
||||
//
|
||||
// 拼在一起会有两个硬伤:
|
||||
// · 回灌时无法区分 —— 思维链会被当成正式回复发给模型(模型会把自己的
|
||||
// 内心独白读成"我说过这话")
|
||||
// · 各自语义不同,合成一块只能有一个向量,检索时无法单独命中"为什么"
|
||||
//
|
||||
// ## 为何 L2/L3 也要它(2026-11-11 用户裁定)
|
||||
//
|
||||
// 此前 L2 只落 Input/Response、L3 蒸馏也不带思考 —— 那是错的:
|
||||
// 决策依据、排除掉的选项、"先查 A 再查 B"的因果链,常常**只**在思考里。
|
||||
// 丢掉它,召回时就只剩"我做了 X",答不出"为何是 X 而不是 Y"。
|
||||
//
|
||||
// 与 L1 的取舍同理:思考是上下文的一部分,不是噪声。
|
||||
RoleReasoning BlockRole = "reasoning"
|
||||
)
|
||||
|
||||
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。
|
||||
|
||||
@ -808,6 +808,12 @@ type ContextEntry struct {
|
||||
Source string
|
||||
Content string
|
||||
Response string
|
||||
// ReasoningContent 是本轮模型的思考内容(thinking 模式的思维链)。
|
||||
//
|
||||
// L2 必须存它(2026-11-11 用户裁定):决策依据、排除掉的选项、
|
||||
// "先查 A 再查 B"的因果链常常只在思考里。只存 Response 会导致
|
||||
// 召回后只能答出"做了什么",答不出"为何这么做"。
|
||||
ReasoningContent string
|
||||
// ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。
|
||||
ToolCalls []ToolCallItem
|
||||
ToolsUsed []string
|
||||
@ -962,6 +968,12 @@ func entryTextBlockID(evtTS time.Time, source, role, text string) string {
|
||||
const (
|
||||
TextBlockRoleInput = "input"
|
||||
TextBlockRoleResponse = "response"
|
||||
// TextBlockRoleReasoning 标记 llm_round 页里的**思考内容**块。
|
||||
//
|
||||
// 单独一类(而不是并进 response)的理由:回灌时要把它们放回不同字段
|
||||
// (ContextEvent.ReasoningContent vs .Response)。合并后回灌会把思维链
|
||||
// 当成正式回复发给模型。
|
||||
TextBlockRoleReasoning = "reasoning"
|
||||
)
|
||||
|
||||
// docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。
|
||||
@ -1027,6 +1039,29 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
// 思考块:与 Response 同属 llm_round 第 1 页,但**单独一块、独立向量**。
|
||||
//
|
||||
// 为何必须落进 coldcontext(2026-11-11 用户裁定):
|
||||
// 决策依据、排除掉的选项、"先查 A 再查 B"的因果链常常只在思考里。
|
||||
// 只存 Response ⇒ 召回后只能答出"做了什么",答不出"为何这么做"。
|
||||
//
|
||||
// 为何不并进上面的 Response 块:回灌时两者要放回**不同字段**
|
||||
// (ContextEvent.ReasoningContent vs .Response);合并后回灌会把
|
||||
// 思维链当成正式回复发给模型。
|
||||
if t := strings.TrimSpace(e.ReasoningContent); t != "" {
|
||||
out = append(out, memory.MemoryBlock{
|
||||
ID: entryTextBlockID(now, e.Source, TextBlockRoleReasoning, t),
|
||||
Modality: memory.BlockText,
|
||||
Text: t,
|
||||
Source: e.Source,
|
||||
FrameTS: frameTS,
|
||||
PageSeq: 1,
|
||||
PageKind: memory.PageLLMRound,
|
||||
BlockRole: memory.RoleReasoning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// 工具轮:每个 toolcall 与它的 result 构成一页。
|
||||
//
|
||||
@ -1149,8 +1184,18 @@ func entryToolBlockID(evtTS time.Time, source string, page int, role memory.Bloc
|
||||
// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后),
|
||||
// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。
|
||||
func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryBlock) {
|
||||
in, resp, _, h := blocksToEntryWithReasoning(ev)
|
||||
return in, resp, h
|
||||
}
|
||||
|
||||
// blocksToEntryWithReasoning 是 blocksToEntry 的完整形态,额外还原思考内容。
|
||||
//
|
||||
// 为何不直接改 blocksToEntry 的签名:它被 8 个调用点使用,其中只有
|
||||
// 回灌(restoreDocEvents)与 L3 蒸馏需要 reasoning。改签名会让 6 个
|
||||
// 不需要它的调用点跟着改——那种改动会淹没真正的语义变化。
|
||||
func blocksToEntryWithReasoning(ev *ColdEvent) (input, response, reasoning string, held []memory.MemoryBlock) {
|
||||
if ev == nil {
|
||||
return "", "", nil
|
||||
return "", "", "", nil
|
||||
}
|
||||
for i := range ev.Blocks {
|
||||
b := ev.Blocks[i]
|
||||
@ -1163,6 +1208,8 @@ func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryB
|
||||
input = b.Text
|
||||
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
|
||||
response = b.Text
|
||||
case reasoning == "" && isEntryTextBlock(b, ev, TextBlockRoleReasoning):
|
||||
reasoning = b.Text
|
||||
case b.PageKind == memory.PageToolRound:
|
||||
// ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults
|
||||
// 的页级表示(P2 派生出来的)。若把它当持有块塞回去,
|
||||
@ -1176,7 +1223,7 @@ func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryB
|
||||
held = append(held, b)
|
||||
}
|
||||
}
|
||||
return input, response, held
|
||||
return input, response, reasoning, held
|
||||
}
|
||||
|
||||
// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。
|
||||
@ -1191,6 +1238,18 @@ func docEventsFromEntries(entries []ContextEntry) []ColdEvent {
|
||||
}
|
||||
out := make([]ColdEvent, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
// ★ 时间戳先归一化,否则块 ID 与事件时间会失配(既有缺陷,2026-11-11 修)。
|
||||
//
|
||||
// 成因:docBlocksForEntry 内部的 now 在 e.Timestamp 为零时退化成 time.Now(),
|
||||
// 而 ColdEvent.Timestamp 保持零值 ⇒ 块 ID 是基于“现在”算的,
|
||||
// 回读却拿零值时间重算 ⇒ 哈希不等 ⇒ isEntryTextBlock 永远不认,
|
||||
// 表现为“块就在那里但一个字段也读不回来”。
|
||||
//
|
||||
// 线上因为 L1 事件总是带 Timestamp 而没暴露,但它是真缺陷:
|
||||
// 任何入口漏填时间戳,事件内容就静默丢失(不报错、不掍日志)。
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now()
|
||||
}
|
||||
ev := ColdEvent{
|
||||
Timestamp: e.Timestamp,
|
||||
Source: e.Source,
|
||||
@ -1259,3 +1318,16 @@ func contentFromEvents(events []ColdEvent) string {
|
||||
func (ev *ColdEvent) InOut() (input, response string, held []memory.MemoryBlock) {
|
||||
return blocksToEntry(ev)
|
||||
}
|
||||
|
||||
// InOutWithReasoning 与 InOut 相同,但额外读回思考内容。
|
||||
//
|
||||
// 用在哪里:需要把事件**完整还原**成 ContextEvent 的地方(L2 回灌、L3 蒸馏)。
|
||||
// 这些地方如果只拿 InOut,回填出的事件就没有 ReasoningContent,
|
||||
// 于是再次裁下时思维链就永久丢了。
|
||||
//
|
||||
// 为何不把 InOut 直接改成多返回值:它被 8 个调用点用,而其中只有 2 处
|
||||
// 需要思考内容。让另外 6 处跟着改,只会把"哪些地方真的关心思考"
|
||||
// 这个信号淹没在一堆样板修改里。
|
||||
func (ev *ColdEvent) InOutWithReasoning() (input, response, reasoning string, held []memory.MemoryBlock) {
|
||||
return blocksToEntryWithReasoning(ev)
|
||||
}
|
||||
|
||||
181
internal/memory/coldcontext/test/reasoning_contract_test.go
Normal file
181
internal/memory/coldcontext/test/reasoning_contract_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
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 里混进了思考内容")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user