- 起因(Android 注册的成本项 H1):`resolveImages` 每回合都跑,而 `inlineImageSrc` 对 `data:` 短路、 对 **URL 每回合重新取回并内联** ⇒ ① 同一张图反复网络 I/O;② 来源一旦失效则该回合解析失败, 而 loop 保留原消息不中断回合 ⇒ **模型这轮静默看不到图** - 修法:按**内容哈希**记忆「URL → data URI」结果(有界 + 并发安全,独立小文件承载); **失败不写缓存**(不把坏结果钉住,第二次仍可重试);命中即不再发起网络请求 - **来源失效不再静默**:取回失败产生可诊断信号(含主机与原因,**不含图片内容/凭据**),回合照旧继续 - 复核:vision 包 build/vet/测试绿(含 `-race`);断言覆盖「同一 URL 连续解析 → 上游只被取回 1 次且结果逐字节相同」 「内容变化不被旧缓存命中」「失败不写缓存且第二次仍重试」「容量上限生效」,以及既有断言 (非图片 `ErrNotImage` / SSRF 被拒 / 长度预检)不回归;变异反证已还原
250 lines
8.5 KiB
Go
250 lines
8.5 KiB
Go
package vision
|
||
|
||
import (
|
||
"container/list"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"net/url"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 取回缓存的边界。取值依据:
|
||
// - 来源条数 64:一次会话里用户附上的不同图片远少于这个数,
|
||
// 超出的老来源按 LRU 淘汰即可,不追求命中率;
|
||
// - 内容条目 256:字节上限虽已按 4/3 的内联膨胀计过,但极小的图片能
|
||
// 堆出极多条目,256 条把 map/list 每项的开销也一并钉住;
|
||
// - 内容字节 64 MiB:内联串约为解码字节的 4/3,单张图解码上限
|
||
// 5 MiB(MaxInlineImageBytes),64 MiB 约合 9 张满额图,够覆盖
|
||
// 一轮对话的附图,同时把常驻内存钉在一个明确上界内;
|
||
// - TTL 10 分钟:一次会话内「同一张图被连续多轮重复解析」的窗口,
|
||
// 也短到让上游换了同一 URL 的内容后最迟 10 分钟自愈(见文件末尾
|
||
// 「失效策略」)。
|
||
const (
|
||
defaultImageCacheMaxSources = 64
|
||
defaultImageCacheMaxContents = 256
|
||
defaultImageCacheMaxBytes = 64 << 20
|
||
defaultImageCacheTTL = 10 * time.Minute
|
||
)
|
||
|
||
// imageCache 按「来源键 → 内容键」两级映射缓存「取回 + 内联」的结果,
|
||
// 使同一张图的 URL 在后续回合不再重复走网络(缺陷 02/H1)。
|
||
//
|
||
// 来源键是 URL 原文(ImageRef.Data),内容键是 sha256(MIME + 0x00 + 取回
|
||
// 字节)。两级分开是为了让同一张图从不同 URL 取回时只留一份 data: URI,
|
||
// 而每条来源映射仍各自记着自己的 TTL;来源条数与内容总字节各有上限,
|
||
// 缓存因此不会无界增长。
|
||
//
|
||
// 并发安全:所有读写都在 mu 之下,取回本身在锁外进行 —— 同一 URL 的
|
||
// 并发未命中可能各取一次(内容相同则收敛到同一个内容键),但结构状态
|
||
// 不会因并发而损坏。
|
||
//
|
||
// 失效策略(明确定义):
|
||
// - 失败不缓存:取回失败(DNS/超时/非 200/非图像)不写任何条目,
|
||
// 下一次解析照常重试,坏结果绝不会被「缓存住」而永久看不到图;
|
||
// - 内容键决定复用:字节变了就是另一个内容键,旧内容不会被顶替它
|
||
// 的新字节命中;
|
||
// - 来源映射有 TTL:同一 URL 的内容在上游变化后,最迟 TTL 到期即
|
||
// 重新取回并刷新映射(TTL 内仍返回旧图,这是缓存换取的确定性代价);
|
||
// - 内容条目被容量淘汰时,指向它的来源映射一并作废,不留死映射。
|
||
type imageCache struct {
|
||
mu sync.Mutex
|
||
ttl time.Duration
|
||
maxSrc int
|
||
maxCtn int
|
||
maxBytes int
|
||
now func() time.Time
|
||
|
||
sources map[string]*sourceNode
|
||
contents map[string]*contentNode
|
||
srcLRU *list.List
|
||
ctnLRU *list.List
|
||
bytes int
|
||
}
|
||
|
||
// sourceNode 是一条来源键映射:它指向内容键,并带自己的过期时刻。
|
||
type sourceNode struct {
|
||
key string
|
||
contentKey string
|
||
expiresAt time.Time
|
||
elem *list.Element
|
||
}
|
||
|
||
// contentNode 是一条内容键条目:存着可供模型直接消费的 data: URI。
|
||
type contentNode struct {
|
||
key string
|
||
dataURI string
|
||
elem *list.Element
|
||
}
|
||
|
||
// newImageCache 构造一个按默认边界运行的缓存。
|
||
func newImageCache() *imageCache {
|
||
return &imageCache{
|
||
ttl: defaultImageCacheTTL,
|
||
maxSrc: defaultImageCacheMaxSources,
|
||
maxCtn: defaultImageCacheMaxContents,
|
||
maxBytes: defaultImageCacheMaxBytes,
|
||
now: time.Now,
|
||
sources: make(map[string]*sourceNode),
|
||
contents: make(map[string]*contentNode),
|
||
srcLRU: list.New(),
|
||
ctnLRU: list.New(),
|
||
}
|
||
}
|
||
|
||
// get 返回 sourceKey 命中的 data: URI。未命中、映射已过期、或内容条目已
|
||
// 被容量淘汰时返回 false,并把失效的那条映射清掉。
|
||
func (c *imageCache) get(sourceKey string) (string, bool) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
node, ok := c.sources[sourceKey]
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
if !c.now().Before(node.expiresAt) {
|
||
c.dropSource(node)
|
||
return "", false
|
||
}
|
||
content, ok := c.contents[node.contentKey]
|
||
if !ok {
|
||
c.dropSource(node)
|
||
return "", false
|
||
}
|
||
c.srcLRU.MoveToFront(node.elem)
|
||
c.ctnLRU.MoveToFront(content.elem)
|
||
return content.dataURI, true
|
||
}
|
||
|
||
// put 记下 sourceKey 这次取回的内容。取回失败的路径不调用它,因此失败的
|
||
// 结果永远不会进缓存(见 imageCache 的「失效策略」)。
|
||
func (c *imageCache) put(sourceKey, mime string, raw []byte, dataURI string) {
|
||
key := contentKeyOf(mime, raw)
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
now := c.now()
|
||
if node, ok := c.sources[sourceKey]; ok {
|
||
node.contentKey = key
|
||
node.expiresAt = now.Add(c.ttl)
|
||
c.srcLRU.MoveToFront(node.elem)
|
||
} else {
|
||
node := &sourceNode{key: sourceKey, contentKey: key, expiresAt: now.Add(c.ttl)}
|
||
node.elem = c.srcLRU.PushFront(node)
|
||
c.sources[sourceKey] = node
|
||
}
|
||
// 同一个内容键必然对应同一份 MIME + 字节,DataURI 是纯函数,
|
||
// 因此已存在的条目与原值逐字节相同,无需覆盖。
|
||
if _, ok := c.contents[key]; !ok {
|
||
content := &contentNode{key: key, dataURI: dataURI}
|
||
content.elem = c.ctnLRU.PushFront(content)
|
||
c.contents[key] = content
|
||
c.bytes += len(dataURI)
|
||
}
|
||
c.evict(now)
|
||
}
|
||
|
||
// evict 把缓存压回上界:先按 LRU 丢内容条目直到总字节与条数达标,再丢
|
||
// 多余的来源映射,最后清掉已过期的来源映射。
|
||
func (c *imageCache) evict(now time.Time) {
|
||
for c.ctnLRU.Len() > 0 && c.bytes > c.maxBytes {
|
||
c.dropContent(c.ctnLRU.Back().Value.(*contentNode))
|
||
}
|
||
for c.ctnLRU.Len() > c.maxCtn {
|
||
c.dropContent(c.ctnLRU.Back().Value.(*contentNode))
|
||
}
|
||
for c.srcLRU.Len() > c.maxSrc {
|
||
c.dropSource(c.srcLRU.Back().Value.(*sourceNode))
|
||
}
|
||
for e := c.srcLRU.Back(); e != nil; {
|
||
prev := e.Prev()
|
||
node := e.Value.(*sourceNode)
|
||
if !now.Before(node.expiresAt) {
|
||
c.dropSource(node)
|
||
}
|
||
e = prev
|
||
}
|
||
}
|
||
|
||
// dropSource 移除一条来源映射;重复调用是安全的。
|
||
func (c *imageCache) dropSource(node *sourceNode) {
|
||
if _, ok := c.sources[node.key]; !ok {
|
||
return
|
||
}
|
||
delete(c.sources, node.key)
|
||
c.srcLRU.Remove(node.elem)
|
||
}
|
||
|
||
// dropContent 移除一条内容条目并同步扣减字节数;指向它的来源映射一并
|
||
// 作废,免得留下永远命中不了的映射占着条数。
|
||
func (c *imageCache) dropContent(node *contentNode) {
|
||
content, ok := c.contents[node.key]
|
||
if !ok {
|
||
return
|
||
}
|
||
delete(c.contents, node.key)
|
||
c.ctnLRU.Remove(content.elem)
|
||
c.bytes -= len(content.dataURI)
|
||
for _, src := range c.sources {
|
||
if src.contentKey == node.key {
|
||
c.dropSource(src)
|
||
}
|
||
}
|
||
}
|
||
|
||
// stats 返回(来源映射数, 内容条目数, 缓存内 data: URI 总字节),
|
||
// 供测试断言上界真的生效。
|
||
func (c *imageCache) stats() (sources, contents, bytes int) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return len(c.sources), len(c.contents), c.bytes
|
||
}
|
||
|
||
// contentKeyOf 计算内容键:sha256(MIME + 0x00 + 取回字节)。MIME 参与哈希,
|
||
// 使「同字节但声明类型不同」的两次取回不会被当成同一条内容互相顶替。
|
||
func contentKeyOf(mime string, raw []byte) string {
|
||
h := sha256.New()
|
||
h.Write([]byte(mime))
|
||
h.Write([]byte{0})
|
||
h.Write(raw)
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|
||
|
||
// inlineImageCached 是解析器使用的「取回 + 内联」入口:src 是 http(s) URL
|
||
// 时命中 c 就直接返回缓存的 data: URI(不再发起网络请求),未命中则取回
|
||
// 后回填。c 为 nil、或 src 不是 URL 时退化为 inlineImageSrc —— data:、
|
||
// base64 与本地路径没有网络开销,不进缓存(也避免把巨型内联串当 map 键)。
|
||
//
|
||
// 失败不写缓存,并返回结构化错误 SourceFailure(只带主机与原因),供
|
||
// 调用方记一条可诊断的 WARN;下一次解析会照常重试。
|
||
func inlineImageCached(ctx context.Context, c *imageCache, src string) (string, error) {
|
||
if c == nil {
|
||
return inlineImageSrc(ctx, src)
|
||
}
|
||
ref, err := ParseImageRef(src)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if ref.Source != SourceURL {
|
||
return inlineImageSrc(ctx, src)
|
||
}
|
||
if uri, ok := c.get(ref.Data); ok {
|
||
return uri, nil
|
||
}
|
||
img, err := inlineImage(ctx, src)
|
||
if err != nil {
|
||
return "", sourceFailure(ref.Data, err)
|
||
}
|
||
c.put(ref.Data, img.MIME, img.Raw, img.URI)
|
||
return img.URI, nil
|
||
}
|
||
|
||
// sourceFailure 把一次 URL 取回失败包成结构化错误,主机取自 URL。
|
||
// 主机用 u.Host(不含 userinfo、路径与查询串),URL 解析失败时留空。
|
||
func sourceFailure(rawURL string, err error) error {
|
||
host := ""
|
||
if u, perr := url.Parse(rawURL); perr == nil {
|
||
host = u.Host
|
||
}
|
||
return &SourceFailure{Host: host, Reason: err}
|
||
}
|