From ce38465dbd7371b9ebb8f6ebe430c865647ebbaa Mon Sep 17 00:00:00 2001 From: xiaoyi Date: Mon, 12 Oct 2026 09:41:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor(distill):=20=E6=89=93=E5=88=86?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=94=B9=E7=94=A8=20Redis=20L2=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E8=87=AA=E9=80=A0=E7=9A=84=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因:牧尘问「是用缓存还是 redis server?」—— 一问点破:初版我自造了 「进程内 map + 每轮 flush 全量重写 JSON 文件」,而**项目本来就有 Redis L2**, 是重复造轮子,且造得更差。 事实核对(改动前实测): - Redis:active + enabled,PONG,21.71MB,30 键 - zhiyid **已强制依赖 Redis**(召回搜索缓存在用 zhiyi:cache:*、事件总线、技能持久化, 启动日志有 [redis] connected)⇒ 用 Redis 不是新增依赖 - 项目已有现成原语:L2CacheBackend 接口 + redisL2Adapter(searchcache.go:16/27) - 我的初版每轮把 2 万条(≈10MB)全量序列化写盘(tmp+rename),Redis 只写增量 改动: 1) score_cache.go 重写为 **L1 进程内 map + L2 Redis 逐键** (键 zhiyi:distill-score:,TTL 7 天,跟随项目既有 zhiyi: 命名空间), 与项目既有 SearchCache(L1+L2) 同构。 - L2 命中回填 L1(下次零网络往返) - Redis 不可用/写失败 → 自动退化为纯 L1,**绝不阻断蒸馏**(只告警一次) - TTL 必须有:本机 Redis 是 maxmemory=0 + noeviction,不设 TTL 只涨不落 2) 删掉约 200 行自造 IO:load/save 全量读写、原子写 tmp+rename、损坏兜底、 filepath 依赖 —— 全部由 Redis 的 GET/SET/TTL 替代 3) 新增 scoreL2 最小接口,**单测注入内存 fake**,不依赖真 Redis 4) 清理废弃文件 /var/lib/memoryweave/distill-score-cache.json 测试(9 例全过): - 新增 TestScoreCacheL2RoundTrip:L1 空的新实例**必须靠 L2 命中**并回填 L1 (这就是「服务一天重启 6 次仍有效」的依据) - 新增 TestScoreCacheL2DownDegrades:L2 挂掉退化为纯 L1,不 panic - 端到端判据不变:同内容第二次调用后 HTTP 请求数**不增加** (证据:首次后=2 同内容后=2 不同内容后=3) ⚠️ 本轮再次「判据写错而非代码错」:TestScoreCacheL2RoundTrip 首版断言 「新实例 L1 为空 ⇒ 不该命中」—— 但新实例本就该靠 L2 命中,那正是改 Redis 的目的。 同一天第二次犯「判据与设计意图不符」(第一次是数 HTTP 请求把探活算进去)。 遗留:真 Redis 的 zhiyi:distill-score:* 键**尚未观测到**(部署后窗口内零流量, 蒸馏队列空)。接口往返已由 fake L2 验证、Set 走的是项目既有的 RedisClient, 但「真键已落」这句话要等下一波摄入后看 redis-cli 才能说。 --- go/internal/distill/score_cache.go | 181 +++++++++++--------- go/internal/distill/score_cache_e2e_test.go | 13 +- go/internal/distill/score_cache_test.go | 125 +++++++++----- 3 files changed, 184 insertions(+), 135 deletions(-) diff --git a/go/internal/distill/score_cache.go b/go/internal/distill/score_cache.go index 39edd8a..d587297 100644 --- a/go/internal/distill/score_cache.go +++ b/go/internal/distill/score_cache.go @@ -5,18 +5,26 @@ // **约 60% 的 2B 打分调用打在重复内容上,纯烧算力**。 // // 为什么不是「批内去重」:观测到的重复是「同一条内容一天 ×9」,**分散在不同批次** -// (每批约 1~15 条),批内去重抓不到。真正对症的是**按内容哈希记忆化** —— -// 同一内容永远只调一次 LLM,之后复用上次结果;批内/跨批/跨重启一并覆盖。 +// (每批约 1~15 条),批内去重抓不到。对症的是**按内容哈希记忆化**: +// 同一内容只调一次 LLM,之后复用;批内 / 跨批 / 跨重启一并覆盖。 // -// 为什么零风险:只是复用「同一输入的上一次输出」。 +// 为什么零风险:只是复用「同一输入的上一次输出」,不改任何判分逻辑。 // ① 最终写入 memories 时本来就会去重(实测 memories 重复率 0.3%), // // 所以跳过重复项的打分**不改变最终写入结果**; // // ② 关掉即恢复原行为:ZHIYI_SCORE_CACHE=0。 // -// 为什么必须落盘:服务重启频繁(今天已重启 5 次),纯内存缓存一重启就清零, -// 覆盖率永远上不去。 +// ── 存储选型(2026-10-12 改 · 起因:牧尘问「是用缓存还是 redis server?」)── +// 初版我自造了「进程内 map + 每轮 flush 全量重写 JSON 文件」。**那是重复造轮子且更差**: +// +// · 项目本来就有 Redis L2(召回搜索缓存 zhiyi:cache:*、事件总线、技能持久化都在用), +// zhiyid **已强制依赖 Redis** ⇒ 用它不是新增依赖; +// · 初版每轮把 2 万条(≈10MB)全量序列化写盘(tmp+rename),Redis 只写增量; +// · TTL 现成,不用自己写淘汰 + 损坏兜底 ⇒ 约 200 行 IO 代码直接删掉。 +// +// 现架构:**L1 进程内 map(命中零网络往返)+ L2 Redis 逐键(TTL 7 天,增量写)**, +// 与项目既有 SearchCache(L1+L2)同构。Redis 不可用时自动退化为纯 L1,**绝不阻断蒸馏**。 package distill import ( @@ -25,37 +33,51 @@ import ( "encoding/json" "log" "os" - "path/filepath" "sync" + "time" + + "github.com/xiaoxue/memoryweave/internal/storage" ) const ( - // scoreCacheCap 上限条数。一条 LLMResponse 序列化约 0.3~1KB ⇒ 2 万条约 6~20MB,可接受。 + // scoreCacheCap L1 内存上限条数(L2 不设总量上限,靠 TTL 自然过期)。 scoreCacheCap = 20000 - // scoreCachePath 落盘位置(与 LanceDB 同目录,服务有写权限)。 - scoreCachePath = "/var/lib/memoryweave/distill-score-cache.json" + // scoreCacheKeyPrefix L2 键前缀(跟随项目既有 zhiyi: 命名空间,便于 SCAN 管理)。 + scoreCacheKeyPrefix = "zhiyi:distill-score:" + // scoreCacheTTL L2 存活期。7 天足够覆盖「同内容一天写 9 次」的窗口, + // 又不会无限膨胀 —— 本机 Redis 是 maxmemory=0 + noeviction, + // 不设 TTL 就只会涨不会落(这个坑今天已经在别处踩过一次)。 + scoreCacheTTL = 7 * 24 * time.Hour ) +// scoreL2 L2 存储的最小接口。抽出来是为了**单测不依赖真 Redis** +// (注入内存 fake 即可确定性地验落盘往返),生产默认用 storage.GetRedisClient()。 +type scoreL2 interface { + Get(key string) (string, error) + Set(key, value string, ttl time.Duration) error +} + type scoreCache struct { mu sync.Mutex m map[string]LLMResponse - order []string // 插入顺序,用于 FIFO 淘汰 - path string + order []string // 插入顺序,用于 L1 FIFO 淘汰 cap int hits int64 miss int64 - dirty bool + + l2 scoreL2 + l2Disabled bool // L2 不可用时置位(只记一次日志,之后退化为纯 L1) } var ( scoreCacheOnce sync.Once globalScoreCache *scoreCache // scoreCacheDisabled 关闭开关。生产由环境变量控制;**单元测试默认置 true** - // (见 score_cache_test.go 的 TestMain)——因为多个测试用同一内容反复调 - // callLLM5D 验证重试/解析行为,缓存会把第二次调用短路,使它们失去覆盖。 + // (见 main_test.go 的 TestMain)——多个测试用同一内容反复调 callLLM5D + // 验证重试/解析行为,缓存会把第二次调用短路,使它们失去覆盖。 scoreCacheDisabled = os.Getenv("ZHIYI_SCORE_CACHE") == "0" - // scoreCacheFilePath 落盘路径,测试可覆盖为临时目录(测试不得读写生产缓存文件)。 - scoreCacheFilePath = scoreCachePath + // scoreCacheL2Override 测试注入点(非 nil 时替代真 Redis)。 + scoreCacheL2Override scoreL2 ) func getScoreCache() *scoreCache { @@ -64,8 +86,20 @@ func getScoreCache() *scoreCache { log.Printf("[scorecache] 已禁用(环境变量 ZHIYI_SCORE_CACHE=0 或测试环境)") return } - c := &scoreCache{m: map[string]LLMResponse{}, path: scoreCacheFilePath, cap: scoreCacheCap} - c.load() + c := &scoreCache{m: map[string]LLMResponse{}, cap: scoreCacheCap} + switch { + case scoreCacheL2Override != nil: + c.l2 = scoreCacheL2Override + default: + if rc := storage.GetRedisClient(); rc != nil { + c.l2 = rc + } else { + c.l2Disabled = true + log.Printf("[scorecache] ⚠️ Redis 不可用 → 退化为纯 L1 内存缓存(不阻断蒸馏)") + } + } + log.Printf("[scorecache] 启用:L1 内存(%d 条) + L2 Redis(%s*, TTL %v)", + scoreCacheCap, scoreCacheKeyPrefix, scoreCacheTTL) globalScoreCache = c }) return globalScoreCache @@ -76,86 +110,70 @@ func scoreKey(content string) string { 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) - } -} - +// get 先查 L1;未命中再查 L2,命中则回填 L1(下次零网络往返)。 +// L2 出错不当作错误,只当未命中 —— 缓存绝不能阻断主流程。 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++ + c.mu.Unlock() return r, true } + c.mu.Unlock() + + if c.l2 != nil && !c.l2Disabled { + if s, err := c.l2.Get(scoreCacheKeyPrefix + k); err == nil && s != "" { + var r LLMResponse + if json.Unmarshal([]byte(s), &r) == nil { + c.admit(k, r) // 回填 L1 + c.mu.Lock() + c.hits++ + c.mu.Unlock() + return r, true + } + } + } + + c.mu.Lock() c.miss++ + c.mu.Unlock() return LLMResponse{}, false } +// put 写 L1 + L2(L2 增量写单键,不再全量重写)。 func (c *scoreCache) put(content string, r LLMResponse) { if c == nil { return } k := scoreKey(content) + c.admit(k, r) + + if c.l2 == nil || c.l2Disabled { + return + } + b, err := json.Marshal(r) + if err != nil { + return + } + if err := c.l2.Set(scoreCacheKeyPrefix+k, string(b), scoreCacheTTL); err != nil { + // 只在第一次失败时告警并退化,避免刷屏 + c.mu.Lock() + first := !c.l2Disabled + c.l2Disabled = true + c.mu.Unlock() + if first { + log.Printf("[scorecache] ⚠️ L2 写入失败,退化为纯 L1(不阻断蒸馏): %v", err) + } + } +} + +// admit 写入 L1 并按上限 FIFO 淘汰。 +func (c *scoreCache) admit(k string, r LLMResponse) { c.mu.Lock() defer c.mu.Unlock() if _, exists := c.m[k]; exists { @@ -163,8 +181,6 @@ func (c *scoreCache) put(content string, r LLMResponse) { } 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:] @@ -181,3 +197,6 @@ func (c *scoreCache) stats() (int64, int64) { defer c.mu.Unlock() return c.hits, c.miss } + +// save 仅为兼容旧调用点保留;L2 逐键即时写,不再需要批量落盘。空实现不得 panic。 +func (c *scoreCache) save() {} diff --git a/go/internal/distill/score_cache_e2e_test.go b/go/internal/distill/score_cache_e2e_test.go index 8723b50..ed00d19 100644 --- a/go/internal/distill/score_cache_e2e_test.go +++ b/go/internal/distill/score_cache_e2e_test.go @@ -3,7 +3,6 @@ package distill import ( "net/http" "net/http/httptest" - "path/filepath" "sync" "sync/atomic" "testing" @@ -11,7 +10,7 @@ import ( // TestScoreCacheSkipsSecondLLMCall —— 决定性验证:**同内容第二次调用不得增加网络请求**。 // -// 「省下约 60% 的 2B 调用」这句话,等价于「第二次遇到同内容时 HTTP 请求数不增加」。 +// 「省下约 60% 的 2B 调用」等价于「第二次遇到同内容时 HTTP 请求数不增加」。 // 只测缓存结构体(不碰网络)证明不了这一点,必须打真 HTTP 桩数请求数。 // // ⚠️ 断言必须用**相对计数**:NewEngine 会走 RefreshConcurrency 探一次端点 @@ -19,14 +18,14 @@ import ( // 被探活干扰成 2 而误报失败 —— 是**判据写错**,不是缓存失效。 func TestScoreCacheSkipsSecondLLMCall(t *testing.T) { // 本包 TestMain 默认关缓存(保护重试/解析类测试),这里显式打开。 - oldDisabled, oldOnce, oldInst, oldPath := scoreCacheDisabled, scoreCacheOnce, globalScoreCache, scoreCacheFilePath + oldDisabled, oldOnce, oldInst, oldL2 := scoreCacheDisabled, scoreCacheOnce, globalScoreCache, scoreCacheL2Override scoreCacheDisabled = false scoreCacheOnce = sync.Once{} globalScoreCache = nil - scoreCacheFilePath = filepath.Join(t.TempDir(), "c.json") + scoreCacheL2Override = newFakeL2() t.Cleanup(func() { scoreCacheDisabled, scoreCacheOnce, globalScoreCache = oldDisabled, oldOnce, oldInst - scoreCacheFilePath = oldPath + scoreCacheL2Override = oldL2 }) var calls int32 @@ -40,7 +39,6 @@ func TestScoreCacheSkipsSecondLLMCall(t *testing.T) { e := NewEngine(srv.URL, "test-model", "test-key") const dup = "牵挂: 待处理: 这条内容今天已经被写过 9 次" - // ① 首次:真调(计数基线含探活) if _, err := e.callLLM5D(dup); err != nil { t.Fatalf("首次调用失败: %v", err) } @@ -49,7 +47,6 @@ func TestScoreCacheSkipsSecondLLMCall(t *testing.T) { t.Fatal("首次调用竟未发出任何 HTTP 请求") } - // ② 同内容再来一次:**计数不得增加**(这就是省下的那一次 2B 调用) if _, err := e.callLLM5D(dup); err != nil { t.Fatalf("第二次调用失败: %v", err) } @@ -58,7 +55,6 @@ func TestScoreCacheSkipsSecondLLMCall(t *testing.T) { t.Fatalf("❌ 同内容第二次仍打了 LLM:%d → %d(应保持不变)", after1, after2) } - // ③ 不同内容:必须真调(不能把不同内容也当命中 —— 那会给出错误分数) if _, err := e.callLLM5D("另一条完全不同的内容"); err != nil { t.Fatalf("第三次调用失败: %v", err) } @@ -67,7 +63,6 @@ func TestScoreCacheSkipsSecondLLMCall(t *testing.T) { t.Fatalf("❌ 不同内容应真调:%d → %d(应增加)", after2, after3) } - // ④ 命中计数与三次调用的语义一致:3 次调用里恰好 1 次命中 if h, _ := getScoreCache().stats(); h != 1 { t.Fatalf("应恰好 1 次命中,实际 %d", h) } diff --git a/go/internal/distill/score_cache_test.go b/go/internal/distill/score_cache_test.go index e6af97e..fd72846 100644 --- a/go/internal/distill/score_cache_test.go +++ b/go/internal/distill/score_cache_test.go @@ -1,20 +1,51 @@ package distill import ( - "os" - "path/filepath" + "errors" + "sync" "testing" + "time" ) -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} +// fakeL2 内存版 L2,用于确定性地验「跨进程/跨重启」路径,且单测不依赖真 Redis。 +type fakeL2 struct { + mu sync.Mutex + m map[string]string + fail bool +} + +func newFakeL2() *fakeL2 { return &fakeL2{m: map[string]string{}} } + +func (f *fakeL2) Get(k string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail { + return "", errors.New("l2 down") + } + v, ok := f.m[k] + if !ok { + return "", errors.New("miss") + } + return v, nil +} + +func (f *fakeL2) Set(k, v string, _ time.Duration) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail { + return errors.New("l2 down") + } + f.m[k] = v + return nil +} + +func newTestCache(cap int) *scoreCache { + return &scoreCache{m: map[string]LLMResponse{}, cap: cap} } // TestScoreCacheHit 同内容第二次必须命中且返回完全一致的结果(这才是"省调用"的前提)。 func TestScoreCacheHit(t *testing.T) { - c := newTestCache(t, 100) + c := newTestCache(100) in := "牵挂: 待处理: 某条重复内容" want := LLMResponse{IS: 0.4, SU: 0.5, PA: 0.3, VD: 0.6, RU: 0.5, Entities: []string{"x"}} @@ -35,9 +66,9 @@ func TestScoreCacheHit(t *testing.T) { } } -// TestScoreCacheDifferentContent 不同内容必须各自独立(绝不能互相污染 —— 那会给出错误分数)。 +// TestScoreCacheDifferentContent 不同内容必须各自独立(互相污染 = 给出错误分数)。 func TestScoreCacheDifferentContent(t *testing.T) { - c := newTestCache(t, 100) + c := newTestCache(100) c.put("A 内容", LLMResponse{IS: 0.1}) c.put("B 内容", LLMResponse{IS: 0.9}) if r, _ := c.get("A 内容"); r.IS != 0.1 { @@ -51,9 +82,9 @@ func TestScoreCacheDifferentContent(t *testing.T) { } } -// TestScoreCacheEviction 超上限必须淘汰而不是无限增长(内存/落盘体积可控)。 +// TestScoreCacheEviction L1 超上限必须淘汰而不是无限增长。 func TestScoreCacheEviction(t *testing.T) { - c := newTestCache(t, 3) + c := newTestCache(3) for _, s := range []string{"1", "2", "3", "4", "5"} { c.put(s, LLMResponse{IS: 0.5}) } @@ -61,59 +92,63 @@ func TestScoreCacheEviction(t *testing.T) { t.Fatalf("淘汰后应恒为 3 条,实际 %d", len(c.m)) } if _, ok := c.get("1"); ok { - t.Fatal("最老的应被淘汰") + t.Fatal("最老的应从 L1 淘汰") } if _, ok := c.get("5"); !ok { t.Fatal("最新的应还在") } } -// TestScoreCachePersist 落盘往返:重启后仍要能命中(否则覆盖率永远上不去)。 -func TestScoreCachePersist(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "c.json") +// TestScoreCacheL2RoundTrip 关键:**新进程实例(L1 空)能靠 L2 命中** —— +// 这就是「服务今天重启 6 次仍有效」的依据。 +func TestScoreCacheL2RoundTrip(t *testing.T) { + l2 := newFakeL2() in := "重启也不该重算的内容" want := LLMResponse{IS: 0.42, Entities: []string{"e1", "e2"}} - c1 := &scoreCache{m: map[string]LLMResponse{}, path: p, cap: 100} + c1 := newTestCache(100) + c1.l2 = l2 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) + // 模拟重启:全新实例、L1 为空、共用同一个 L2。 + // ⚠️ 首版这里断言「新实例不该命中」—— **判据写错了**:新实例本就是靠 L2 命中的, + // 那正是改用 Redis 的目的。正确的断言见下:L1 为空时**必须**命中(走 L2)。 + c3 := newTestCache(100) + c3.l2 = l2 + if len(c3.m) != 0 { + t.Fatal("新实例 L1 应为空(否则测的不是 L2 路径)") + } + got, ok := c3.get(in) if !ok { - t.Fatal("重启后(重新 load)应命中") + t.Fatal("❌ L1 为空时应能从 L2 命中(否则重启后缓存全失效)") } if got.IS != want.IS || len(got.Entities) != 2 { - t.Fatalf("恢复结果不一致: %+v", got) + t.Fatalf("L2 恢复结果不一致: %+v", got) + } + if len(c3.m) == 0 { + t.Fatal("L2 命中后应回填 L1(否则每次都吃网络往返)") } } -// 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) +// TestScoreCacheL2DownDegrades L2 挂掉只能退化为纯 L1,绝不能阻断蒸馏。 +func TestScoreCacheL2DownDegrades(t *testing.T) { + l2 := newFakeL2() + l2.fail = true + c := newTestCache(100) + c.l2 = l2 + + c.put("x", LLMResponse{IS: 0.5}) // L2 写失败 → 应退化,不 panic + if !c.l2Disabled { + t.Fatal("L2 写失败后应置 l2Disabled") } - 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("损坏恢复后仍应可正常写入/命中") + if got, ok := c.get("x"); !ok || got.IS != 0.5 { + t.Fatal("L2 挂掉后 L1 仍须正常命中") } } -// TestScoreKeyStable 同内容哈希必须稳定(含中文字节安全);不同内容不得碰撞。 +// TestScoreKeyStable 哈希稳定(中文 UTF-8 安全)且不同内容不碰撞。 func TestScoreKeyStable(t *testing.T) { - a1, a2 := scoreKey("牵挂: 待拍板: 笔记→织忆蒸馏补齐"), scoreKey("牵挂: 待拍板: 笔记→织忆蒸馏补齐") - if a1 != a2 { + if scoreKey("牵挂: 待拍板: 笔记→织忆蒸馏补齐") != scoreKey("牵挂: 待拍板: 笔记→织忆蒸馏补齐") { t.Fatal("同内容哈希不稳定") } if scoreKey("甲") == scoreKey("乙") { @@ -121,15 +156,15 @@ func TestScoreKeyStable(t *testing.T) { } } -// TestScoreCacheNilSafe 未启用(getScoreCache 返回 nil)时不得 panic。 +// 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 + c.put("x", LLMResponse{}) if h, m := c.stats(); h != 0 || m != 0 { t.Fatal("nil 缓存统计应为 0") } - c.save() // 不得 panic + c.save() }