Commit Graph

2 Commits

Author SHA1 Message Date
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
3384cee62a test: 按模块导出接口重写黑盒判据(批次 1:config/knowledge/distill/agent-api)
## 方向

「面向模块输入接受模块输出」:只调模块**本来就有**的导出接口,
输入 → 断言可观察输出。不从旧白盒里抽、不读源码、不引用文档、
不构造内部结构、不为测试导出任何新符号。

4 个文件全部放 `<module>/test/`,源码目录仍零 `_test.go`。
共 23 条判据,每模块 1 个文件。

## 各模块的接口清单与判据

### internal/config —— NewConfigRegistry / PluginConfig

    TestCoreSetGetDelete                  Set → Get → Delete → Get=nil
    TestPluginNamespaceIsIsolated         两插件同名 key 不串台;核心表看不到插件键
    TestPluginListByPrefix                List 只返回匹配前缀的键
    TestDefRoundTripPreservesSecret       RegisterDef → ListDefs 保住 Secret
    TestSecretValueIsNotPlaintextOnDisk   Set → GetSecret 往返 + DB 里无明文

**判据按真实契约写**:插件表由 `RegisterDef` 创建,`Set` 不隐式建表。
第一版没先 RegisterDef,实测 `no such table: config_alpha` —— 不是缺陷,
是我把装配顺序当成了实现细节。判据里显式写了这条契约。

### internal/knowledge —— NewStore(root) / Add / List / Stats / Remove / Search / Categories

    TestAddThenListAndStats                  Add → List/Stats
    TestRemoveActuallyRemoves                Remove → 空;二次 Remove 报错
    TestPathTraversalIsRejected             ".." / "." / 空名 必须被拒
    TestCategoriesIncludeIntermediateLevels  tech/go/叶名 ⇒ tech、tech/go 都出现
    TestSearchRanksRelevantAboveUnrelated   只问排序,不钉数值

`TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1
那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。

**判据写错过一次并修正**:`Categories()` 返回的是**父路径**,
`tech/go/concurrency` 条目的 Category 是 `tech/go`,不含叶名。
第一版断言叶名也在列表里 ⇒ 红。查实现后按真实契约改成两层。

### internal/memory/distill —— 纯函数,输入输出就是全部语义

    TestBlockTextRoundTrip              ToMemoryBlock → BlockSubject/Dimension/Value
    TestBlockIDDependsOnSubject        不同主语不得撞 ID(静默数据丢失)
    TestBlockIDIsDeterministic         同输入同输出
    TestDimensionValueEdgeCases        无 '=' / 空维度 / 多 '='
    TestSentenceBlockIDIsStable        原句块 ID 稳定
    TestNormalizeDimensionIsIdempotent 归一幂等

### internal/agent/api —— 纯函数 + ProviderManager 公开状态机

    TestEstimateTokensIsMonotonic      更长文本不得更少 token
    TestTruncateByTokensRespectsBudget 截断后不超预算
    TestModelContextWindowAlwaysPositive
    TestIsValidSourceConfig            四字段 + URL scheme 真值表
    TestRegisterGetListDefault         注册顺序/按名取回/默认源切换
    TestAvailabilityStateMachine       MarkUnavailable → 不可用 → Reset 恢复
    TestProviderErrorCarriesStatusAndMessage

`ProviderManager` 用的是真 `NewProviderManager()` + 满足 `Provider`
接口的 stub,不碰 `m.status`、不用任何 ForTest 钩子。

## 变异验证(每条至少一次,改回旧实现必须红)

    config  ListDefs 丢 Secret        → ★ Secret:true 在 RegisterDef → ListDefs 后丢失
    knowledge normalizeName 放行 ".." → ★ Add("..") 被接受了 —— 路径可逃出知识根
    distill BlockID 丢 subject        → ★ 不同主语得到同一个 BlockID
    api 漏 URL scheme 校验            → ★ ftp://x 判成了 true

全部还原后复跑全绿。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    9 个测试包 / 100 个包无测试 / 0 FAIL
    4 个新包 -race                            全绿
    lens 4 个新文件                           LSP 0 诊断
2026-11-09 11:57:48 +08:00