Files
sundynix-agentix/sundynix-dispatcher/internal/llm/cache_test.go
T
Blizzard e80e481f9f feat(llm): T2.3 输出缓存 —— 同输入命中跳过 LLM 调用(省成本+提速)
在 eino model 层加 cachingModel 装饰器,包在 failover 链最外层:命中直接跳过整条链。

- 只缓存 Generate(非流式):Stream 是用户可见的创作型输出、重复率低且回放复杂,透传不缓存。
- 键 = 模型名 + 绑定工具哈希 + 消息内容哈希,不同模型/工具集/输入互不串味。
- 默认 TTL 60s(env LLM_CACHE_TTL_S,0=关):只覆盖短窗内的重试/双发/重复点击 —— 这类
  几乎一定同一意图,命中省成本+提速;又短到不让助手对同一问题长期"复读"。容量上限
  LLM_CACHE_MAX(512) 满则随机淘汰。换激活模型 → 键含模型名自然失效。

测试:命中跳底层/不同输入不串味/TTL 过期重调/流式不缓存/工具集不同键/TTL=0 禁用。

诚实说明:本平台以创作型流式为主、且 Generate 输入(专家简报/评测)每次都变,**真实命中率
天然偏低**——主要吃"短窗内逐字相同"的重试/双发。机制正确、零风险(可 env 关),但不是大
成本杠杆;更大的省钱项(语义缓存/确定性工具结果缓存)是后续。Prompt 版本管理(T2.3 另一半)
未做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:40:20 +08:00

96 lines
3.2 KiB
Go

package llm
import (
"context"
"testing"
"time"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
func msgs(text string) []*schema.Message { return []*schema.Message{schema.UserMessage(text)} }
// TestCache_HitSkipsInner 同输入第二次命中缓存,不再调底层模型。
func TestCache_HitSkipsInner(t *testing.T) {
var calls int
cm := &cachingModel{
inner: &fakeModel{reply: "答案", calls: &calls},
cache: newRespCache(time.Minute, 100),
keyExtra: "m:test",
}
ctx := context.Background()
a1, _ := cm.Generate(ctx, msgs("问题"))
a2, _ := cm.Generate(ctx, msgs("问题"))
if a1.Content != "答案" || a2.Content != "答案" {
t.Fatalf("两次都应得答案, got %q %q", a1.Content, a2.Content)
}
if calls != 1 {
t.Fatalf("第二次应命中缓存、底层只调一次,got calls=%d", calls)
}
}
// TestCache_DifferentInputMiss 不同输入不串味,各调一次。
func TestCache_DifferentInputMiss(t *testing.T) {
var calls int
cm := &cachingModel{inner: &fakeModel{reply: "x", calls: &calls}, cache: newRespCache(time.Minute, 100), keyExtra: "m:test"}
ctx := context.Background()
cm.Generate(ctx, msgs("A"))
cm.Generate(ctx, msgs("B"))
if calls != 2 {
t.Fatalf("不同输入应各调一次,got calls=%d", calls)
}
}
// TestCache_TTLExpiry 过期后重新调底层。
func TestCache_TTLExpiry(t *testing.T) {
var calls int
cm := &cachingModel{inner: &fakeModel{reply: "x", calls: &calls}, cache: newRespCache(20*time.Millisecond, 100), keyExtra: "m:test"}
ctx := context.Background()
cm.Generate(ctx, msgs("Q"))
time.Sleep(40 * time.Millisecond)
cm.Generate(ctx, msgs("Q")) // 已过期 → 再调
if calls != 2 {
t.Fatalf("过期后应重新调底层,got calls=%d", calls)
}
}
// TestCache_StreamNotCached 流式不缓存,每次都调底层。
func TestCache_StreamNotCached(t *testing.T) {
var calls int
cm := &cachingModel{inner: &fakeModel{reply: "x", calls: &calls}, cache: newRespCache(time.Minute, 100), keyExtra: "m:test"}
ctx := context.Background()
for i := 0; i < 2; i++ {
sr, _ := cm.Stream(ctx, msgs("Q"))
sr.Close()
}
if calls != 2 {
t.Fatalf("流式不应缓存,got calls=%d", calls)
}
}
// TestCache_WithToolsDifferentKey 不同工具集 → 不同缓存键,不串味。
func TestCache_WithToolsDifferentKey(t *testing.T) {
var calls int
base := &cachingModel{inner: &fakeModel{reply: "x", calls: &calls}, cache: newRespCache(time.Minute, 100), keyExtra: "m:test"}
ctx := context.Background()
toolA, _ := base.WithTools([]*schema.ToolInfo{{Name: "a", Desc: "A"}})
toolB, _ := base.WithTools([]*schema.ToolInfo{{Name: "b", Desc: "B"}})
toolA.Generate(ctx, msgs("Q"))
toolB.Generate(ctx, msgs("Q")) // 同输入但工具集不同 → 不命中
if calls != 2 {
t.Fatalf("不同工具集不应串味,got calls=%d", calls)
}
}
// TestWithCache_Disabled TTL=0 时不包缓存,直接返回原模型。
func TestWithCache_Disabled(t *testing.T) {
t.Setenv("LLM_CACHE_TTL_S", "0")
inner := &fakeModel{reply: "x"}
got := withCache(inner, "test")
if _, isCaching := got.(*cachingModel); isCaching {
t.Fatal("TTL=0 应禁用缓存,不应包 cachingModel")
}
var _ model.ToolCallingChatModel = got
}