refactor(distill): 打分缓存改用 Redis L2,删掉自造的文件缓存

起因:牧尘问「是用缓存还是 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:<sha256>,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 才能说。
This commit is contained in:
xiaoyi
2026-10-12 09:41:02 +08:00
parent a162e2769e
commit ce38465dbd
3 changed files with 184 additions and 135 deletions
+100 -81
View File
@@ -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() {}
+4 -9
View File
@@ -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)
}
+80 -45
View File
@@ -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()
}