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:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user