perf(vision): 图片 URL 的「取回 + 内联」按内容哈希 memo(协同 H1:每回合重复付费/取回)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 起因(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:
AskaEth committed 2026-10-04 16:32:13 +08:00
1 parent b65fe05635
commit 94b882f4d9
5 files changed
+682 -8

No files matched your search

@@ -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
}