Files
Cyrene_v2/internal/runtime/multimodal/vision/resolver.go
T
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

139 lines
5.3 KiB
Go
Raw 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 (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// ModelInfoProvider 提供主模型的视觉能力,使解析器
// 能按 vision Router 的判定(design/06 §0)路由图像。它按
// 图像逐个调用;同一次解析中共享同一个 provider。
type ModelInfoProvider func() ModelInfo
// ToolInvoker 按名字与给定参数调用一个已注册的工具(视觉或 OCR
// 工具)。由 *tool.Dispatcher.Invoke 满足。
type ToolInvoker func(ctx context.Context, name string, args map[string]any) (any, error)
// MessageResolver 是 loop 的具体 ImageResolver。对用户消息中的
// 每个图像部件,它向 vision Router 询问如何路由:当主模型
// 支持视觉且有信心时,保留该图像部件让主模型看到
// 图像;否则调用 vision/OCR 工具,用其文字结果替换
// 图像部件,使纯文本主模型也能对图像推理(「先看图再
// 对话」)。它可安全并发使用。
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(),
}
}
// Resolve 改写 msg,使每个图像部件要么被保留(主模型能看到它),
// 要么被 vision/OCR 工具产出的文字描述替换。文本部件
// 按原顺序保留。它仅在图像无法路由或工具
// 调用失败时报错;此时调用方(loop)保留原始消息。
func (r *MessageResolver) Resolve(ctx context.Context, msg model.Message) (model.Message, error) {
if r.router == nil {
return msg, nil
}
out := make([]model.Content, 0, len(msg.Content))
for _, part := range msg.Content {
if part.Type != model.ContentTypeImage {
out = append(out, part)
continue
}
replacement, err := r.resolveImage(ctx, part.ImageURL)
if err != nil {
return model.Message{}, err
}
out = append(out, replacement)
}
return model.Message{Role: msg.Role, Content: out}, nil
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
target := ModelInfo{}
if r.info != nil {
target = r.info()
}
decision := r.router.Route(target, RouteRequest{})
switch decision.Kind {
case KindMain:
// 支持视觉的主模型直接看到这张图;但 URL 与本地路径必须先由服务端
// 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址
// (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是
// 只接受 base64 data: URI(model/anthropic_encode.go 会拒绝裸 URL)。
// 取回失败时返回结构化错误(带来源主机与原因),由 loop 记录一条
// 可诊断的 WARN 并保留原消息(该轮不因此中断);失败不进缓存,
// 下一回合会照常重试,因此不会「永久看不到图」。
src, err := inlineImageCached(ctx, r.cache, imageURL)
if err != nil {
return model.Content{}, err
}
return model.Content{Type: model.ContentTypeImage, ImageURL: src}, nil
case KindVisionTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
"prompt": "Describe this image in detail.",
}, "description")
case KindOCRTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
}, "text")
default:
return model.Content{}, fmt.Errorf("image route: %w: %s", ErrUnsupportedSource, decision.Reason)
}
}
// invokeTool 运行一个已注册的 vision/OCR 工具,并把其文字结果转成
// 文本内容部件。field 是结果 map 的键("description" 或 "text")。
func (r *MessageResolver) invokeTool(ctx context.Context, toolName string, args map[string]any, field string) (model.Content, error) {
if r.invoke == nil {
return model.Content{}, fmt.Errorf("%s: %w", toolName, ErrNotConfigured)
}
result, err := r.invoke(ctx, toolName, args)
if err != nil {
return model.Content{}, fmt.Errorf("image tool %s: %w", toolName, err)
}
return model.Content{Type: model.ContentTypeText, Text: descriptionFragment(fieldText(result, field))}, nil
}
// fieldText 从工具结果的 map 中取出字符串字段,兼容
// vision/OCR 工具返回的 map[string]any 形状。
func fieldText(result any, field string) string {
m, ok := result.(map[string]any)
if !ok {
return ""
}
s, _ := m[field].(string)
return s
}
// descriptionFragment 给工具产出的描述加上标注,使模型能看出它
// 指的是用户附上的那张图。
func descriptionFragment(desc string) string {
if desc == "" {
return "[图片内容] 无法识别该图片"
}
return "[图片内容] " + desc
}