Files
sundynix-agentix/sundynix-dispatcher/internal/llm/cache.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

153 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package llm
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// cacheTTLSeconds 是输出缓存的过期时长(env LLM_CACHE_TTL_S,单位秒;0=关闭缓存)。默认 60s:
// 只覆盖"短时间内的重试/重复点击/双发"——这类几乎一定是同一意图,命中省成本+提速;
// 又短到不会让助手对同一问题长期"复读"同一答案(创作型输出仍随时间变化)。
func cacheTTLSeconds() time.Duration {
// envInt 允许 0(关闭);默认 60 秒。
if v := os.Getenv("LLM_CACHE_TTL_S"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return time.Duration(n) * time.Second
}
}
return 60 * time.Second
}
// cacheMax 是缓存条目上限(env LLM_CACHE_MAX,默认 512)。满了随机淘汰一条。
func cacheMax() int {
if v := os.Getenv("LLM_CACHE_MAX"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 512
}
// respCache 是带 TTL + 容量上限的 Generate 结果缓存(并发安全)。
type respCache struct {
mu sync.Mutex
m map[string]cacheEntry
ttl time.Duration
max int
hits int64
misses int64
}
type cacheEntry struct {
msg *schema.Message
exp time.Time
}
func newRespCache(ttl time.Duration, max int) *respCache {
return &respCache{m: make(map[string]cacheEntry), ttl: ttl, max: max}
}
func (c *respCache) get(key string) (*schema.Message, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.m[key]
if !ok || time.Now().After(e.exp) {
if ok {
delete(c.m, key) // 过期清理
}
atomic.AddInt64(&c.misses, 1)
return nil, false
}
atomic.AddInt64(&c.hits, 1)
return e.msg, true
}
func (c *respCache) put(key string, msg *schema.Message) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.m) >= c.max {
for k := range c.m { // 满 → 随机淘汰一条(map 迭代序随机)
delete(c.m, k)
break
}
}
c.m[key] = cacheEntry{msg: msg, exp: time.Now().Add(c.ttl)}
}
// cachingModel 缓存 Generate 结果:同输入命中则跳过 LLM 调用(省成本+提速)。
// 只缓存 Generate(非流式);Stream 是用户可见的创作型输出,重复率低且回放复杂,直接透传不缓存。
// 键 = keyExtra(模型名 + 绑定工具哈希)+ 消息内容哈希——确保不同模型/工具集/输入互不串味。
type cachingModel struct {
inner model.ToolCallingChatModel
cache *respCache
keyExtra string
}
// withCache 把模型包成带缓存的(ttl<=0 则不包,直接返回原模型)。modelID 用于隔离不同模型的缓存。
func withCache(inner model.ToolCallingChatModel, modelID string) model.ToolCallingChatModel {
ttl := cacheTTLSeconds()
if ttl <= 0 {
return inner
}
return &cachingModel{inner: inner, cache: newRespCache(ttl, cacheMax()), keyExtra: "m:" + modelID}
}
func (c *cachingModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
key := c.keyExtra + "|" + hashMessages(input)
if msg, ok := c.cache.get(key); ok {
fmt.Printf("[llm] 输出缓存命中(跳过 LLM 调用,命中/未命中=%d/%d\n",
atomic.LoadInt64(&c.cache.hits), atomic.LoadInt64(&c.cache.misses))
return msg, nil
}
out, err := c.inner.Generate(ctx, input, opts...)
if err == nil && out != nil {
c.cache.put(key, out)
}
return out, err
}
// Stream 不缓存(用户可见的创作型输出),直接透传。
func (c *cachingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return c.inner.Stream(ctx, input, opts...)
}
// WithTools 给内层绑工具,返回共享同一缓存但键加了工具哈希的新包装(不同工具集不串味)。
func (c *cachingModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
inner, err := c.inner.WithTools(tools)
if err != nil {
return nil, err
}
return &cachingModel{inner: inner, cache: c.cache, keyExtra: c.keyExtra + "|tools:" + hashTools(tools)}, nil
}
// hashMessages 把消息序列(角色+内容)哈希为稳定 key。
func hashMessages(msgs []*schema.Message) string {
h := sha256.New()
for _, m := range msgs {
fmt.Fprintf(h, "%s\x00%s\x01", m.Role, m.Content)
}
return hex.EncodeToString(h.Sum(nil))
}
// hashTools 把绑定工具的名字+描述哈希(工具集不同 → 缓存键不同)。
func hashTools(tools []*schema.ToolInfo) string {
if len(tools) == 0 {
return "none"
}
h := sha256.New()
for _, t := range tools {
fmt.Fprintf(h, "%s\x00%s\x01", t.Name, t.Desc)
}
return hex.EncodeToString(h.Sum(nil))[:16]
}