perf(distill): 打分结果按内容哈希记忆化 —— 省掉约 60% 的 2B 调用

问题(实测):
- episodes 表重复率 78.5%(近 2 天 58.4%,今天 64.3%);同一条内容一天被写 9 次
- 干净窗口 392 条评分里**只有 146 条唯一** ⇒ 约 60% 的 2B 打分调用打在重复内容上

为什么不是批内去重:观测到的重复是「同内容一天 ×9」,**分散在不同批次**
(每批 1~15 条),批内去重抓不到。对症方案是按内容哈希记忆化 ——
同内容只调一次 LLM,之后复用;批内/跨批/跨重启一并覆盖。

改动:
1) 新增 distill/score_cache.go:sha256(content) → LLMResponse 的 FIFO 有界缓存
   (cap 2 万条),**落盘**(原子写:tmp+rename)——服务今天已重启 6 次,
   纯内存缓存覆盖率永远上不去。损坏文件只从空开始,不阻断蒸馏。
2) engine.go:原 callLLM5D 改名 callLLM5DUncached(**函数体零改动**),
   外层新增 callLLM5D 做「查缓存 → 未命中再调 → 回填」。
   禁用开关 ZHIYI_SCORE_CACHE=0。
3) flush 收尾打印累计命中率并落盘(每轮一次,避免频繁 IO)。

零风险论证:只复用「同一输入的上一次输出」,不改变任何判分逻辑;
最终写入 memories 时本来就去重(实测 memories 重复率 0.3%),
所以跳过重复项的打分**不改变最终写入结果**。

测试:新增 8 个用例(命中/不同内容不污染/淘汰边界/落盘往返/损坏兜底/
哈希稳定/nil 安全/端到端不打第二次 HTTP),全部通过。
决定性判据 = 端到端:同内容第二次调用后 HTTP 请求数**不增加**
(证据:首次后=2 同内容后=2 不同内容后=3)。

⚠️ 过程中发现两件必须记下的事:
① 测试守护:本包 TestMain 默认**关闭**缓存 —— 有 4 个测试用同一内容反复调
   callLLM5D 验证重试/解析行为,缓存会把第二次短路,使它们**静默失去覆盖**
   (表面全绿)。首版没关,4 个测试当场挂掉才暴露。
② 判据写错:首版断言绝对计数 '==1',被 NewEngine 的并发探活
   (RefreshConcurrency 打一次端点)干扰成 2 而误报失败 —— 是判据错,非缓存失效。
   改为相对计数后通过。
This commit is contained in:
xiaoyi
2026-10-12 09:23:09 +08:00
parent 13cf7d2088
commit a162e2769e
5 changed files with 440 additions and 0 deletions
+28
View File
@@ -491,6 +491,17 @@ func (e *Engine) flush() {
e.mu.Unlock()
log.Printf("[distill] flush DONE: processed %d items (concurrency=%d)", len(batch), effConcurrency)
// 打分缓存汇总(2026-10-12):命中率直接等于「本轮省下的 2B 调用占比」。
// 落盘放这里(每轮一次)而不是每条一次,避免频繁 IO。
if c := getScoreCache(); c != nil {
h, m := c.stats()
if h+m > 0 {
log.Printf("[scorecache] 累计 命中=%d 未命中=%d 命中率=%.1f%%(命中=省下的 2B 调用)",
h, m, float64(h)/float64(h+m)*100)
}
c.save()
}
}
// distillOne 蒸馏单条
@@ -612,7 +623,24 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
}
// callLLM5D 调用 LLM 进行 5维评估 + 实体/事实提取
// callLLM5D 打分入口 —— 先查内容哈希缓存,命中直接复用(不调 2B)。
//
// 2026-10-12:episodes 重复率 78.5%、约 60% 的打分调用打在重复内容上(详见 score_cache.go)。
// 本层只做记忆化,**不改变任何判分逻辑**;禁用见 ZHIYI_SCORE_CACHE=0。
func (e *Engine) callLLM5D(content string) (LLMResponse, error) {
c := getScoreCache()
if r, ok := c.get(content); ok {
return r, nil
}
r, err := e.callLLM5DUncached(content)
if err == nil {
c.put(content, r)
}
return r, err
}
// callLLM5DUncached 原始打分实现(函数体未改动,仅改名,便于外层套缓存)。
func (e *Engine) callLLM5DUncached(content string) (LLMResponse, error) {
// 2026-09-14 限制 distill prompt ≤8k token,防止打爆本地 2B 单槽
content, sentTokens, origTokens := limitDistillContent(content)
if origTokens > maxDistillTokens {
+19
View File
@@ -0,0 +1,19 @@
package distill
import (
"os"
"testing"
)
// TestMain 默认关闭打分缓存。
//
// 为什么必须关:TestCallLLM5D_LineProtocolParse / BackoffOn429 / BackoffOn5xx /
// FlushPeakRespectsSlotMatchedConcurrency 都用**同一内容**反复调 callLLM5D,
// 靠第二次调用验证「重试」「解析」行为。缓存一旦生效,第二次直接被短路返回,
// 这些测试就**静默失去了覆盖**(表面全绿,实则没测到东西)。
//
// 缓存本身的正确性由 score_cache_test.go 直接测结构体(不依赖全局实例)。
func TestMain(m *testing.M) {
scoreCacheDisabled = true
os.Exit(m.Run())
}
+183
View File
@@ -0,0 +1,183 @@
// 织忆 MemoryWeave — 打分结果记忆化缓存(2026-10-12)
//
// 要治的问题(实测):episodes 表**重复率 78.5%**(近 2 天 58.4%,今天 64.3%),
// 同一条内容一天被写成 9 次;干净窗口 392 条评分里**只有 146 条唯一** ⇒
// **约 60% 的 2B 打分调用打在重复内容上,纯烧算力**。
//
// 为什么不是「批内去重」:观测到的重复是「同一条内容一天 ×9」,**分散在不同批次**
// (每批约 1~15 条),批内去重抓不到。真正对症的是**按内容哈希记忆化** ——
// 同一内容永远只调一次 LLM,之后复用上次结果;批内/跨批/跨重启一并覆盖。
//
// 为什么零风险:只是复用「同一输入的上一次输出」。
// ① 最终写入 memories 时本来就会去重(实测 memories 重复率 0.3%),
//
// 所以跳过重复项的打分**不改变最终写入结果**;
//
// ② 关掉即恢复原行为:ZHIYI_SCORE_CACHE=0。
//
// 为什么必须落盘:服务重启频繁(今天已重启 5 次),纯内存缓存一重启就清零,
// 覆盖率永远上不去。
package distill
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
)
const (
// scoreCacheCap 上限条数。一条 LLMResponse 序列化约 0.3~1KB ⇒ 2 万条约 6~20MB,可接受。
scoreCacheCap = 20000
// scoreCachePath 落盘位置(与 LanceDB 同目录,服务有写权限)。
scoreCachePath = "/var/lib/memoryweave/distill-score-cache.json"
)
type scoreCache struct {
mu sync.Mutex
m map[string]LLMResponse
order []string // 插入顺序,用于 FIFO 淘汰
path string
cap int
hits int64
miss int64
dirty bool
}
var (
scoreCacheOnce sync.Once
globalScoreCache *scoreCache
// scoreCacheDisabled 关闭开关。生产由环境变量控制;**单元测试默认置 true**
// (见 score_cache_test.go 的 TestMain)——因为多个测试用同一内容反复调
// callLLM5D 验证重试/解析行为,缓存会把第二次调用短路,使它们失去覆盖。
scoreCacheDisabled = os.Getenv("ZHIYI_SCORE_CACHE") == "0"
// scoreCacheFilePath 落盘路径,测试可覆盖为临时目录(测试不得读写生产缓存文件)。
scoreCacheFilePath = scoreCachePath
)
func getScoreCache() *scoreCache {
scoreCacheOnce.Do(func() {
if scoreCacheDisabled {
log.Printf("[scorecache] 已禁用(环境变量 ZHIYI_SCORE_CACHE=0 或测试环境)")
return
}
c := &scoreCache{m: map[string]LLMResponse{}, path: scoreCacheFilePath, cap: scoreCacheCap}
c.load()
globalScoreCache = c
})
return globalScoreCache
}
func scoreKey(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
// load 从磁盘恢复;文件不存在/损坏都只是「从空开始」,不阻断蒸馏。
func (c *scoreCache) load() {
b, err := os.ReadFile(c.path)
if err != nil {
log.Printf("[scorecache] 无历史缓存(%v),从空开始", err)
return
}
var payload struct {
Order []string `json:"order"`
Items map[string]LLMResponse `json:"items"`
}
if err := json.Unmarshal(b, &payload); err != nil {
log.Printf("[scorecache] ⚠️ 缓存文件解析失败,从空开始(不阻断): %v", err)
return
}
c.m = payload.Items
if c.m == nil {
c.m = map[string]LLMResponse{}
}
c.order = payload.Order
log.Printf("[scorecache] 已载入 %d 条历史打分(%s)", len(c.m), c.path)
}
// save 落盘(原子写:先写临时文件再 rename,避免写一半被重启切成坏文件)。
// 判空:ZHIYI_SCORE_CACHE=0 时全局实例为 nil,任何方法都不得 panic。
func (c *scoreCache) save() {
if c == nil {
return
}
c.mu.Lock()
if !c.dirty {
c.mu.Unlock()
return
}
payload := struct {
Order []string `json:"order"`
Items map[string]LLMResponse `json:"items"`
}{c.order, c.m}
c.dirty = false
c.mu.Unlock()
b, err := json.Marshal(payload)
if err != nil {
log.Printf("[scorecache] 序列化失败: %v", err)
return
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
log.Printf("[scorecache] 建目录失败: %v", err)
return
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
log.Printf("[scorecache] 写临时文件失败: %v", err)
return
}
if err := os.Rename(tmp, c.path); err != nil {
log.Printf("[scorecache] rename 失败: %v", err)
}
}
func (c *scoreCache) get(content string) (LLMResponse, bool) {
if c == nil {
return LLMResponse{}, false
}
k := scoreKey(content)
c.mu.Lock()
defer c.mu.Unlock()
if r, ok := c.m[k]; ok {
c.hits++
return r, true
}
c.miss++
return LLMResponse{}, false
}
func (c *scoreCache) put(content string, r LLMResponse) {
if c == nil {
return
}
k := scoreKey(content)
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.m[k]; exists {
return
}
c.m[k] = r
c.order = append(c.order, k)
c.dirty = true
// FIFO 淘汰:超上限时丢最老的,直到回到上限。
for len(c.order) > c.cap {
old := c.order[0]
c.order = c.order[1:]
delete(c.m, old)
}
}
// stats 返回 (命中, 未命中) 快照,供每轮 flush 汇总打印。
func (c *scoreCache) stats() (int64, int64) {
if c == nil {
return 0, 0
}
c.mu.Lock()
defer c.mu.Unlock()
return c.hits, c.miss
}
@@ -0,0 +1,75 @@
package distill
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
)
// TestScoreCacheSkipsSecondLLMCall —— 决定性验证:**同内容第二次调用不得增加网络请求**。
//
// 「省下约 60% 的 2B 调用」这句话,等价于「第二次遇到同内容时 HTTP 请求数不增加」。
// 只测缓存结构体(不碰网络)证明不了这一点,必须打真 HTTP 桩数请求数。
//
// ⚠️ 断言必须用**相对计数**:NewEngine 会走 RefreshConcurrency 探一次端点
// (日志 total_slots=…),那次探活也计入桩的请求数。首版用绝对值 "==1" 判,
// 被探活干扰成 2 而误报失败 —— 是**判据写错**,不是缓存失效。
func TestScoreCacheSkipsSecondLLMCall(t *testing.T) {
// 本包 TestMain 默认关缓存(保护重试/解析类测试),这里显式打开。
oldDisabled, oldOnce, oldInst, oldPath := scoreCacheDisabled, scoreCacheOnce, globalScoreCache, scoreCacheFilePath
scoreCacheDisabled = false
scoreCacheOnce = sync.Once{}
globalScoreCache = nil
scoreCacheFilePath = filepath.Join(t.TempDir(), "c.json")
t.Cleanup(func() {
scoreCacheDisabled, scoreCacheOnce, globalScoreCache = oldDisabled, oldOnce, oldInst
scoreCacheFilePath = oldPath
})
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(testLLMBody))
}))
defer srv.Close()
e := NewEngine(srv.URL, "test-model", "test-key")
const dup = "牵挂: 待处理: 这条内容今天已经被写过 9 次"
// ① 首次:真调(计数基线含探活)
if _, err := e.callLLM5D(dup); err != nil {
t.Fatalf("首次调用失败: %v", err)
}
after1 := atomic.LoadInt32(&calls)
if after1 == 0 {
t.Fatal("首次调用竟未发出任何 HTTP 请求")
}
// ② 同内容再来一次:**计数不得增加**(这就是省下的那一次 2B 调用)
if _, err := e.callLLM5D(dup); err != nil {
t.Fatalf("第二次调用失败: %v", err)
}
after2 := atomic.LoadInt32(&calls)
if after2 != after1 {
t.Fatalf("❌ 同内容第二次仍打了 LLM:%d → %d(应保持不变)", after1, after2)
}
// ③ 不同内容:必须真调(不能把不同内容也当命中 —— 那会给出错误分数)
if _, err := e.callLLM5D("另一条完全不同的内容"); err != nil {
t.Fatalf("第三次调用失败: %v", err)
}
after3 := atomic.LoadInt32(&calls)
if after3 <= after2 {
t.Fatalf("❌ 不同内容应真调:%d → %d(应增加)", after2, after3)
}
// ④ 命中计数与三次调用的语义一致:3 次调用里恰好 1 次命中
if h, _ := getScoreCache().stats(); h != 1 {
t.Fatalf("应恰好 1 次命中,实际 %d", h)
}
t.Logf("证据:请求数 首次后=%d 同内容后=%d 不同内容后=%d;命中=1", after1, after2, after3)
}
+135
View File
@@ -0,0 +1,135 @@
package distill
import (
"os"
"path/filepath"
"testing"
)
func newTestCache(t *testing.T, cap int) *scoreCache {
t.Helper()
p := filepath.Join(t.TempDir(), "c.json")
return &scoreCache{m: map[string]LLMResponse{}, path: p, cap: cap}
}
// TestScoreCacheHit 同内容第二次必须命中且返回完全一致的结果(这才是"省调用"的前提)。
func TestScoreCacheHit(t *testing.T) {
c := newTestCache(t, 100)
in := "牵挂: 待处理: 某条重复内容"
want := LLMResponse{IS: 0.4, SU: 0.5, PA: 0.3, VD: 0.6, RU: 0.5, Entities: []string{"x"}}
if _, ok := c.get(in); ok {
t.Fatal("空缓存不应命中")
}
c.put(in, want)
got, ok := c.get(in)
if !ok {
t.Fatal("同内容第二次应命中")
}
if got.IS != want.IS || got.VD != want.VD || len(got.Entities) != 1 {
t.Fatalf("命中返回的结果与写入不一致: %+v", got)
}
if h, m := c.stats(); h != 1 || m != 1 {
t.Fatalf("计数器不符:hits=%d miss=%d(期望 1/1)", h, m)
}
}
// TestScoreCacheDifferentContent 不同内容必须各自独立(绝不能互相污染 —— 那会给出错误分数)。
func TestScoreCacheDifferentContent(t *testing.T) {
c := newTestCache(t, 100)
c.put("A 内容", LLMResponse{IS: 0.1})
c.put("B 内容", LLMResponse{IS: 0.9})
if r, _ := c.get("A 内容"); r.IS != 0.1 {
t.Fatalf("A 被污染: %+v", r)
}
if r, _ := c.get("B 内容"); r.IS != 0.9 {
t.Fatalf("B 被污染: %+v", r)
}
if _, ok := c.get("C 内容"); ok {
t.Fatal("未写入过的内容不应命中")
}
}
// TestScoreCacheEviction 超上限必须淘汰而不是无限增长(内存/落盘体积可控)。
func TestScoreCacheEviction(t *testing.T) {
c := newTestCache(t, 3)
for _, s := range []string{"1", "2", "3", "4", "5"} {
c.put(s, LLMResponse{IS: 0.5})
}
if len(c.m) != 3 {
t.Fatalf("淘汰后应恒为 3 条,实际 %d", len(c.m))
}
if _, ok := c.get("1"); ok {
t.Fatal("最老的应被淘汰")
}
if _, ok := c.get("5"); !ok {
t.Fatal("最新的应还在")
}
}
// TestScoreCachePersist 落盘往返:重启后仍要能命中(否则覆盖率永远上不去)。
func TestScoreCachePersist(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "c.json")
in := "重启也不该重算的内容"
want := LLMResponse{IS: 0.42, Entities: []string{"e1", "e2"}}
c1 := &scoreCache{m: map[string]LLMResponse{}, path: p, cap: 100}
c1.put(in, want)
c1.save()
if _, err := os.Stat(p); err != nil {
t.Fatalf("落盘文件不存在: %v", err)
}
c2 := &scoreCache{m: map[string]LLMResponse{}, path: p, cap: 100}
c2.load()
got, ok := c2.get(in)
if !ok {
t.Fatal("重启后(重新 load)应命中")
}
if got.IS != want.IS || len(got.Entities) != 2 {
t.Fatalf("恢复结果不一致: %+v", got)
}
}
// TestScoreCacheCorruptFileStartsEmpty 缓存文件损坏只能"从空开始",绝不能阻断蒸馏。
func TestScoreCacheCorruptFileStartsEmpty(t *testing.T) {
p := filepath.Join(t.TempDir(), "c.json")
if err := os.WriteFile(p, []byte("{ 这不是合法 JSON"), 0o644); err != nil {
t.Fatal(err)
}
c := &scoreCache{m: map[string]LLMResponse{}, path: p, cap: 10}
c.load() // 不得 panic / 不得 fatal
if len(c.m) != 0 {
t.Fatalf("损坏文件应从空开始,实际 %d 条", len(c.m))
}
c.put("x", LLMResponse{IS: 0.5})
if _, ok := c.get("x"); !ok {
t.Fatal("损坏恢复后仍应可正常写入/命中")
}
}
// TestScoreKeyStable 同内容哈希必须稳定(含中文字节安全);不同内容不得碰撞。
func TestScoreKeyStable(t *testing.T) {
a1, a2 := scoreKey("牵挂: 待拍板: 笔记→织忆蒸馏补齐"), scoreKey("牵挂: 待拍板: 笔记→织忆蒸馏补齐")
if a1 != a2 {
t.Fatal("同内容哈希不稳定")
}
if scoreKey("甲") == scoreKey("乙") {
t.Fatal("不同内容哈希碰撞")
}
}
// TestScoreCacheNilSafe 未启用(getScoreCache 返回 nil)时不得 panic。
func TestScoreCacheNilSafe(t *testing.T) {
var c *scoreCache
if _, ok := c.get("x"); ok {
t.Fatal("nil 缓存不应命中")
}
c.put("x", LLMResponse{}) // 不得 panic
if h, m := c.stats(); h != 0 || m != 0 {
t.Fatal("nil 缓存统计应为 0")
}
c.save() // 不得 panic
}