diff --git a/internal/runtime/multimodal/vision/cache.go b/internal/runtime/multimodal/vision/cache.go new file mode 100644 index 0000000..7371115 --- /dev/null +++ b/internal/runtime/multimodal/vision/cache.go @@ -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} +} diff --git a/internal/runtime/multimodal/vision/cache_test.go b/internal/runtime/multimodal/vision/cache_test.go new file mode 100644 index 0000000..f430506 --- /dev/null +++ b/internal/runtime/multimodal/vision/cache_test.go @@ -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) + } +} diff --git a/internal/runtime/multimodal/vision/errors.go b/internal/runtime/multimodal/vision/errors.go index 3405b5b..e66a31b 100644 --- a/internal/runtime/multimodal/vision/errors.go +++ b/internal/runtime/multimodal/vision/errors.go @@ -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 } diff --git a/internal/runtime/multimodal/vision/image.go b/internal/runtime/multimodal/vision/image.go index b06f6eb..57cdab9 100644 --- a/internal/runtime/multimodal/vision/image.go +++ b/internal/runtime/multimodal/vision/image.go @@ -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:;base64," 前缀。src 不是 diff --git a/internal/runtime/multimodal/vision/resolver.go b/internal/runtime/multimodal/vision/resolver.go index ffe147c..bce61d6 100644 --- a/internal/runtime/multimodal/vision/resolver.go +++ b/internal/runtime/multimodal/vision/resolver.go @@ -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 }