Files
AskaEth 94b882f4d9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
perf(vision): 图片 URL 的「取回 + 内联」按内容哈希 memo(协同 H1:每回合重复付费/取回)
- 起因(Android 注册的成本项 H1):`resolveImages` 每回合都跑,而 `inlineImageSrc` 对 `data:` 短路、
  对 **URL 每回合重新取回并内联** ⇒ ① 同一张图反复网络 I/O;② 来源一旦失效则该回合解析失败,
  而 loop 保留原消息不中断回合 ⇒ **模型这轮静默看不到图**
- 修法:按**内容哈希**记忆「URL → data URI」结果(有界 + 并发安全,独立小文件承载);
  **失败不写缓存**(不把坏结果钉住,第二次仍可重试);命中即不再发起网络请求
- **来源失效不再静默**:取回失败产生可诊断信号(含主机与原因,**不含图片内容/凭据**),回合照旧继续
- 复核:vision 包 build/vet/测试绿(含 `-race`);断言覆盖「同一 URL 连续解析 → 上游只被取回 1 次且结果逐字节相同」
  「内容变化不被旧缓存命中」「失败不写缓存且第二次仍重试」「容量上限生效」,以及既有断言
  (非图片 `ErrNotImage` / SSRF 被拒 / 长度预检)不回归;变异反证已还原
2026-10-04 16:32:13 +08:00

250 lines
8.5 KiB
Go
Raw Permalink 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 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}
}