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 被拒 / 长度预检)不回归;变异反证已还原
This commit is contained in:
1 parent
b65fe05635
commit
94b882f4d9
5 files changed
+682
-8
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// pngBytesAlt 构造一张与 pngBytes 字节不同的合法 PNG,用于「同一 URL
|
||||
// 内容变了」的用例。
|
||||
func pngBytesAlt(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
img.Set(0, 0, color.RGBA{R: 0xff, G: 0xee, B: 0xdd, A: 0xff})
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode alt png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// newMainModelResolver 构造一个把图像路由到主模型的解析器(主模型声明
|
||||
// 支持视觉),工具路径被调用即视为测试失败。
|
||||
func newMainModelResolver() *MessageResolver {
|
||||
return NewMessageResolver(NewRouter(WithVisionTool("vision")), func() ModelInfo {
|
||||
return ModelInfo{Name: "vision-model", SupportsVision: true}
|
||||
}, func(_ context.Context, _ string, _ map[string]any) (any, error) {
|
||||
return nil, ErrNotConfigured
|
||||
})
|
||||
}
|
||||
|
||||
// resolveMainImage 走一遍解析器的主模型路径,返回内联后的图像串。
|
||||
func resolveMainImage(res *MessageResolver, src string) (string, error) {
|
||||
msg, err := res.Resolve(context.Background(), model.ImageMessage(model.RoleUser, "x", src))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, p := range msg.Content {
|
||||
if p.Type == model.ContentTypeImage {
|
||||
return p.ImageURL, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("解析结果里没有图像部件")
|
||||
}
|
||||
|
||||
// TestImageCache_SecondResolveFetchesOnce 是本缺陷的主用例:同一个 URL
|
||||
// 连续解析两次(两个回合),上游只被取回一次,两次结果逐字节相同。
|
||||
func TestImageCache_SecondResolveFetchesOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
png := pngBytes(t)
|
||||
var hits atomic.Int32
|
||||
imgSrv := newImageServer(t, png, "image/png", &hits)
|
||||
res := newMainModelResolver()
|
||||
src := imgSrv.URL + "/cat.png"
|
||||
|
||||
first, err := resolveMainImage(res, src)
|
||||
if err != nil {
|
||||
t.Fatalf("第一次解析: %v", err)
|
||||
}
|
||||
second, err := resolveMainImage(res, src)
|
||||
if err != nil {
|
||||
t.Fatalf("第二次解析: %v", err)
|
||||
}
|
||||
want := DataURI("image/png", png)
|
||||
if first != want || second != want {
|
||||
t.Fatalf("内联结果 = (%q, %q),want %q", first, second, want)
|
||||
}
|
||||
if got := hits.Load(); got != 1 {
|
||||
t.Fatalf("上游被取回 %d 次,want 1(第二个回合必须命中缓存)", got)
|
||||
}
|
||||
if sources, _, _ := res.cache.stats(); sources != 1 {
|
||||
t.Fatalf("缓存来源条数 = %d,want 1", sources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageCache_RefetchesAfterTTLWhenContentChanges 钉住失效策略:TTL 内
|
||||
// 返回缓存(旧内容,这是缓存换取的确定性代价),TTL 到期后重新取回,上游
|
||||
// 换了内容也能自愈 —— 新字节是新内容键,不会被旧条目命中。
|
||||
func TestImageCache_RefetchesAfterTTLWhenContentChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
original := pngBytes(t)
|
||||
changed := pngBytesAlt(t)
|
||||
var mu sync.Mutex
|
||||
payload := original
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
mu.Lock()
|
||||
body := payload
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res := newMainModelResolver()
|
||||
res.cache.ttl = 20 * time.Millisecond
|
||||
src := srv.URL + "/cat.png"
|
||||
|
||||
first, err := resolveMainImage(res, src)
|
||||
if err != nil {
|
||||
t.Fatalf("第一次解析: %v", err)
|
||||
}
|
||||
if first != DataURI("image/png", original) {
|
||||
t.Fatalf("第一次内联结果不对: %q", first)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
payload = changed
|
||||
mu.Unlock()
|
||||
|
||||
// TTL 内:仍返回旧图,且没有再取回。
|
||||
if got, err := resolveMainImage(res, src); err != nil || got != first {
|
||||
t.Fatalf("TTL 内 = (%q, %v),want 旧缓存 %q", got, err, first)
|
||||
}
|
||||
if got := hits.Load(); got != 1 {
|
||||
t.Fatalf("TTL 内上游被取回 %d 次,want 1", got)
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
third, err := resolveMainImage(res, src)
|
||||
if err != nil {
|
||||
t.Fatalf("TTL 到期后解析: %v", err)
|
||||
}
|
||||
wantChanged := DataURI("image/png", changed)
|
||||
if third != wantChanged {
|
||||
t.Fatalf("TTL 到期后 = %q,want 新内容 %q(旧映射没有失效)", third, wantChanged)
|
||||
}
|
||||
if got := hits.Load(); got != 2 {
|
||||
t.Fatalf("上游被取回 %d 次,want 2(TTL 到期必须重取)", got)
|
||||
}
|
||||
|
||||
// 刷新后的映射必须指向新内容本身:若回填只换了来源映射、内容条目仍是
|
||||
// 旧 URI,下一次命中就会把旧图重新喂给模型。
|
||||
if got, err := resolveMainImage(res, src); err != nil || got != wantChanged {
|
||||
t.Fatalf("刷新后的缓存命中 = (%q, %v),want %q(回填没有更新内容)", got, err, wantChanged)
|
||||
}
|
||||
if got := hits.Load(); got != 2 {
|
||||
t.Fatalf("刷新后的命中又取回了上游(%d 次),want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageCache_FailureIsNotCachedAndDiagnosable 覆盖失败路径:取回失败
|
||||
// 不写缓存、第二次仍会重试,错误是结构化的 SourceFailure —— 只带来源主机
|
||||
// 与原因,不带 URL 路径、查询串与凭据,且宿主能按结构断言取到主机(loop
|
||||
// 的 WARN 因此可诊断)。
|
||||
func TestImageCache_FailureIsNotCachedAndDiagnosable(t *testing.T) {
|
||||
t.Parallel()
|
||||
png := pngBytes(t)
|
||||
var hits atomic.Int32
|
||||
var failing atomic.Bool
|
||||
failing.Store(true)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
if failing.Load() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(png)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
wantHost, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server url: %v", err)
|
||||
}
|
||||
res := newMainModelResolver()
|
||||
// 带查询串的地址:错误文案里绝不能出现凭据或路径。
|
||||
src := srv.URL + "/cat.png?token=SEKRET"
|
||||
|
||||
_, err = resolveMainImage(res, src)
|
||||
if !errors.Is(err, ErrFetch) {
|
||||
t.Fatalf("第一次错误 = %v,want ErrFetch", err)
|
||||
}
|
||||
var sf *SourceFailure
|
||||
if !errors.As(err, &sf) {
|
||||
t.Fatalf("错误不是 *SourceFailure,调用方拿不到来源主机: %v", err)
|
||||
}
|
||||
if sf.Host != wantHost.Host {
|
||||
t.Fatalf("Host = %q,want %q", sf.Host, wantHost.Host)
|
||||
}
|
||||
var withHost interface{ ImageSourceHost() string }
|
||||
if !errors.As(err, &withHost) || withHost.ImageSourceHost() != wantHost.Host {
|
||||
t.Fatalf("错误未按结构断言暴露来源主机(loop 的 WARN 会丢 host): %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"SEKRET", "token", "cat.png"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Fatalf("错误文案泄漏了 URL(%q): %v", leak, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, wantHost.Host) {
|
||||
t.Fatalf("错误文案没有来源主机: %v", err)
|
||||
}
|
||||
if sources, contents, bytes := res.cache.stats(); sources != 0 || contents != 0 || bytes != 0 {
|
||||
t.Fatalf("失败结果被写进了缓存: sources=%d contents=%d bytes=%d", sources, contents, bytes)
|
||||
}
|
||||
|
||||
failing.Store(false)
|
||||
got, err := resolveMainImage(res, src)
|
||||
if err != nil {
|
||||
t.Fatalf("第二次解析(来源已恢复): %v", err)
|
||||
}
|
||||
if want := DataURI("image/png", png); got != want {
|
||||
t.Fatalf("第二次解析 = %q,want %q", got, want)
|
||||
}
|
||||
if n := hits.Load(); n != 2 {
|
||||
t.Fatalf("上游被取回 %d 次,want 2(失败必须可重试)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageCache_NotImageIsNotCached 是既有断言(非图像 → ErrNotImage)
|
||||
// 在缓存路径上的延伸:HTML 错误页报错,且不会被缓存住。
|
||||
func TestImageCache_NotImageIsNotCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
var hits atomic.Int32
|
||||
imgSrv := newImageServer(t, htmlBytes(), "image/png", &hits)
|
||||
res := newMainModelResolver()
|
||||
src := imgSrv.URL + "/err"
|
||||
|
||||
for attempt := 1; attempt <= 2; attempt++ {
|
||||
_, err := resolveMainImage(res, src)
|
||||
if !errors.Is(err, ErrNotImage) {
|
||||
t.Fatalf("第 %d 次错误 = %v,want ErrNotImage", attempt, err)
|
||||
}
|
||||
}
|
||||
if got := hits.Load(); got != 2 {
|
||||
t.Fatalf("上游被取回 %d 次,want 2(非图像不得进缓存)", got)
|
||||
}
|
||||
if sources, contents, _ := res.cache.stats(); sources != 0 || contents != 0 {
|
||||
t.Fatalf("非图像被写进了缓存: sources=%d contents=%d", sources, contents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageCache_BoundsAreEnforced 钉住边界:来源条数与内容总字节都不会
|
||||
// 无界增长,最老的条目按 LRU 淘汰(淘汰后不再命中)。
|
||||
func TestImageCache_BoundsAreEnforced(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newImageCache()
|
||||
c.maxSrc = 2
|
||||
c.maxCtn = 1000 // 条数上限放到很大,这里只让字节上限与来源上限起作用
|
||||
uri := strings.Repeat("A", 200)
|
||||
c.maxBytes = 3 * len(uri)
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
c.put(fmt.Sprintf("https://example.com/%d.png", i), "image/png", []byte{byte(i)}, uri)
|
||||
}
|
||||
sources, contents, bytes := c.stats()
|
||||
if sources > c.maxSrc {
|
||||
t.Fatalf("来源条数 = %d,超过上限 %d", sources, c.maxSrc)
|
||||
}
|
||||
if bytes > c.maxBytes {
|
||||
t.Fatalf("缓存字节 = %d,超过上限 %d", bytes, c.maxBytes)
|
||||
}
|
||||
if contents > c.maxCtn {
|
||||
t.Fatalf("内容条目数 = %d,超过条数上限 %d", contents, c.maxCtn)
|
||||
}
|
||||
if maxByBytes := c.maxBytes / len(uri); contents > maxByBytes {
|
||||
t.Fatalf("内容条目数 = %d,超过按字节上限换算的 %d", contents, maxByBytes)
|
||||
}
|
||||
if _, ok := c.get("https://example.com/0.png"); ok {
|
||||
t.Fatal("最老的来源仍在缓存里(LRU 淘汰没有生效)")
|
||||
}
|
||||
if _, ok := c.get("https://example.com/49.png"); !ok {
|
||||
t.Fatal("最新的来源不在缓存里")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageCache_ContentCountCapEnforced 单独钉住内容条目数上限:字节上限
|
||||
// 放得很大时,只有条数上限起作用 —— 极小的图片堆不出极多的条目,字节数
|
||||
// 也随之收敛,最老的内容被 LRU 淘汰。
|
||||
func TestImageCache_ContentCountCapEnforced(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newImageCache()
|
||||
c.maxSrc = 1000
|
||||
c.maxCtn = 4
|
||||
c.maxBytes = 1 << 30
|
||||
uri := strings.Repeat("A", 200)
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
c.put(fmt.Sprintf("https://example.com/%d.png", i), "image/png", []byte{byte(i)}, uri)
|
||||
}
|
||||
sources, contents, bytes := c.stats()
|
||||
if contents > c.maxCtn {
|
||||
t.Fatalf("内容条目数 = %d,超过条数上限 %d", contents, c.maxCtn)
|
||||
}
|
||||
if want := c.maxCtn * len(uri); bytes != want {
|
||||
t.Fatalf("缓存字节 = %d,want %d(字节数应随条数上限收敛)", bytes, want)
|
||||
}
|
||||
if sources > c.maxSrc {
|
||||
t.Fatalf("来源条数 = %d,超过上限 %d", sources, c.maxSrc)
|
||||
}
|
||||
if _, ok := c.get("https://example.com/0.png"); ok {
|
||||
t.Fatal("最老的内容仍在缓存里(LRU 淘汰没有生效)")
|
||||
}
|
||||
if _, ok := c.get("https://example.com/49.png"); !ok {
|
||||
t.Fatal("最新的内容不在缓存里")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageCache_ConcurrentResolveIsSafe 覆盖并发解析同一 URL:结构状态
|
||||
// 不被破坏(配合 -race),结果一致;并发风暴之后的解析必定命中缓存。
|
||||
func TestImageCache_ConcurrentResolveIsSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
png := pngBytes(t)
|
||||
var hits atomic.Int32
|
||||
imgSrv := newImageServer(t, png, "image/png", &hits)
|
||||
res := newMainModelResolver()
|
||||
src := imgSrv.URL + "/cat.png"
|
||||
want := DataURI("image/png", png)
|
||||
|
||||
const workers = 8
|
||||
results := make([]string, workers)
|
||||
errs := make([]error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i], errs[i] = resolveMainImage(res, src)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("并发解析 %d: %v", i, errs[i])
|
||||
}
|
||||
if results[i] != want {
|
||||
t.Fatalf("并发解析 %d = %q,want %q", i, results[i], want)
|
||||
}
|
||||
}
|
||||
if got := hits.Load(); got == 0 || got > workers {
|
||||
t.Fatalf("上游被取回 %d 次,want 1..%d", got, workers)
|
||||
}
|
||||
|
||||
before := hits.Load()
|
||||
if got, err := resolveMainImage(res, src); err != nil || got != want {
|
||||
t.Fatalf("并发风暴后 = (%q, %v),want %q", got, err, want)
|
||||
}
|
||||
if after := hits.Load(); after != before {
|
||||
t.Fatalf("并发风暴后的解析仍取回了上游(%d → %d),缓存没生效", before, after)
|
||||
}
|
||||
sources, _, _ := res.cache.stats()
|
||||
if sources != 1 {
|
||||
t.Fatalf("缓存来源条数 = %d,want 1(同一 URL 只应有一条映射)", sources)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package vision
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// vision 包返回的哨兵错误。每个错误向外传递时都以 %w
|
||||
// 包装,使调用方能按类别判断,同时保留原始原因。
|
||||
@@ -29,3 +32,33 @@ var (
|
||||
// 判定在解码与短路之前完成,错误信息只带上限与本次长度。
|
||||
ErrImageTooLarge = errors.New("vision: inline image is too large")
|
||||
)
|
||||
|
||||
// SourceFailure 描述一次图像来源取回失败。它只携带主机与底层原因:完整
|
||||
// URL 可能带着查询串里的凭据、图像字节可能含隐私内容,两者都不进错误
|
||||
// 文案。Host 取不到(URL 无法解析)时为空串。
|
||||
//
|
||||
// 它实现 Unwrap,因此底层哨兵(ErrFetch / ErrNotImage /
|
||||
// ErrBlockedAddress / ErrImageTooLarge …)仍可用 errors.Is 判定;
|
||||
// 调用方用 errors.As(*SourceFailure) 或结构断言 ImageSourceHost()
|
||||
// 取出主机,把「来源失效」记成 WARN 而不是静默丢图(见 loop/vision.go)。
|
||||
type SourceFailure struct {
|
||||
// Host 是来源 URL 的主机(含端口),不含 userinfo、路径与查询串。
|
||||
Host string
|
||||
// Reason 是取回失败的底层原因,保留哨兵链供 errors.Is 判定。
|
||||
Reason error
|
||||
}
|
||||
|
||||
// Error 渲染主机与原因;Host 为空时只呈现原因。
|
||||
func (e *SourceFailure) Error() string {
|
||||
if e.Host == "" {
|
||||
return e.Reason.Error()
|
||||
}
|
||||
return fmt.Sprintf("图像来源取回失败(主机 %s):%v", e.Host, e.Reason)
|
||||
}
|
||||
|
||||
// Unwrap 暴露底层原因,使哨兵判定与具体错误类型照常可用。
|
||||
func (e *SourceFailure) Unwrap() error { return e.Reason }
|
||||
|
||||
// ImageSourceHost 报告来源主机,供调用方按结构断言取用(loop 的解析点),
|
||||
// 使 loop 无需依赖本包的取回实现。
|
||||
func (e *SourceFailure) ImageSourceHost() string { return e.Host }
|
||||
@@ -135,21 +135,42 @@ func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string,
|
||||
return mime, data, nil
|
||||
}
|
||||
|
||||
// inlinedImage 是一次「取回 + 内联」的产物。MIME 与 Raw 只为按内容哈希
|
||||
// 缓存而返回(见 cache.go 的 imageCache),绝不写进日志。
|
||||
type inlinedImage struct {
|
||||
// URI 是可供模型直接消费的 data: URI。
|
||||
URI string
|
||||
// MIME 是归一化后的图像 MIME;data: 输入(纯短路路径)时为空。
|
||||
MIME string
|
||||
// Raw 是内联前的原始字节;data: 输入(纯短路路径)时为 nil。
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
// inlineImageSrc 把图像来源字符串取回并内联为 data: URI,供主模型
|
||||
// 直接消费。已是 data: URI 的原样返回(客户端现有的 base64 路径因此
|
||||
// 逐字节不变),但**短路之前**也要过长度预检:这条路径不解码,不预检
|
||||
// 就等于把体积上限完全交给入口层。URL 取回复用 Resolve→fetchURL 的既有
|
||||
// 守卫(协议白名单 + 体积上限 + 地址守卫),并加取回超时。
|
||||
func inlineImageSrc(ctx context.Context, src string) (string, error) {
|
||||
img, err := inlineImage(ctx, src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return img.URI, nil
|
||||
}
|
||||
|
||||
// inlineImage 与 inlineImageSrc 同一条路径,但额外返回内联前的 MIME 与
|
||||
// 原始字节,供 cache.go 按内容哈希 memo 化(同一 URL 不再每回合重复取回)。
|
||||
func inlineImage(ctx context.Context, src string) (inlinedImage, error) {
|
||||
if strings.HasPrefix(src, "data:") {
|
||||
if err := checkInlineImageLength(src); err != nil {
|
||||
return "", err
|
||||
return inlinedImage{}, err
|
||||
}
|
||||
return src, nil
|
||||
return inlinedImage{URI: src}, nil
|
||||
}
|
||||
ref, err := ParseImageRef(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return inlinedImage{}, err
|
||||
}
|
||||
if ref.Source == SourceURL {
|
||||
var cancel context.CancelFunc
|
||||
@@ -158,9 +179,9 @@ func inlineImageSrc(ctx context.Context, src string) (string, error) {
|
||||
}
|
||||
mime, data, err := Resolve(ctx, ref, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return inlinedImage{}, err
|
||||
}
|
||||
return DataURI(mime, data), nil
|
||||
return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil
|
||||
}
|
||||
|
||||
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是
|
||||
|
||||
@@ -26,17 +26,22 @@ type MessageResolver struct {
|
||||
router *Router
|
||||
info ModelInfoProvider
|
||||
invoke ToolInvoker
|
||||
cache *imageCache
|
||||
}
|
||||
|
||||
// NewMessageResolver 构造一个 MessageResolver。router 决定
|
||||
// 分层;info 报告主模型的视觉能力;invoke 运行一个已注册的 vision/OCR
|
||||
// 工具。invoke 为 nil 时任何工具路由都会失败(此时图像被保留),
|
||||
// 因此没有工具的解析器会降级到主模型路径。
|
||||
//
|
||||
// 解析器自带一个按内容哈希 memo 化的取回缓存:解析器在接线处构造一次并
|
||||
// 长期复用,同一 URL 图因此只在首个回合真正取回一次(见 cache.go)。
|
||||
func NewMessageResolver(router *Router, info ModelInfoProvider, invoke ToolInvoker) *MessageResolver {
|
||||
return &MessageResolver{
|
||||
router: router,
|
||||
info: info,
|
||||
invoke: invoke,
|
||||
cache: newImageCache(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +82,10 @@ func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (mo
|
||||
// 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址
|
||||
// (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是
|
||||
// 只接受 base64 data: URI(model/anthropic_encode.go 会拒绝裸 URL)。
|
||||
// 取回失败时返回错误,由 loop 保留原消息(该轮不因此中断)。
|
||||
src, err := inlineImageSrc(ctx, imageURL)
|
||||
// 取回失败时返回结构化错误(带来源主机与原因),由 loop 记录一条
|
||||
// 可诊断的 WARN 并保留原消息(该轮不因此中断);失败不进缓存,
|
||||
// 下一回合会照常重试,因此不会「永久看不到图」。
|
||||
src, err := inlineImageCached(ctx, r.cache, imageURL)
|
||||
if err != nil {
|
||||
return model.Content{}, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user