VLM 内容审核误判怎么破:Go 实现多模型 Fallback 链

当你的应用调用视觉大模型(VLM)解析用户上传的图片时,会遇到一个隐蔽的问题:内容审核误判。一张完全正常的截图,某个 VLM 供应商可能判定为"敏感内容"直接返回 422 拒绝处理。用户看到的是"解析失败",但实际原因是模型供应商的安全策略过于保守。

这篇文章介绍一种用 Go 实现的多模型 fallback 方案:主模型被内容审核拒绝时,自动切换到备用模型完成请求,用户端完全无感。

问题背景

视觉大模型在处理金融类截图(持仓页面、交易记录、账户资产)时经常触发内容审核。典型场景:

模型现象原因
MiniMax-M3HTTP 422,error code 1026,image is sensitive截图中包含"账户资产""仓位比例"等行,被判定为完整财务凭证
GLM-4.6V-FlashHTTP 429免费模型限流严重

422 和 429 性质完全不同:429 重试可以解决,422 重试多少次都会被拒(同一张图同一模型)。只有换一个模型才行。

架构设计

三层调用链,每层职责明确:

callVisionWithFallback  ← 模型切换层(422 时换模型)
  └─ callVisionWithRetry  ← 重试层(429/5xx 指数退避)
       └─ callVision  ← 实际 HTTP 调用

VLM Fallback 架构图

核心思路:把"可重试错误"(429/5xx/网络超时)和"内容审核拒绝"(422)分成两个独立处理路径。可重试的退避重试,内容审核拒绝的直接跳到下一个模型。

实现:模型配置

首先定义一个 visionModel 结构体描述每个 VLM 后端:

go
type visionModel struct {
    ID      string // 短标识符("glm"、"minimax")
    Label   string // 前端显示名
    Model   string // API 实际模型名
    APIKey  string
    APIBase string
}

构造函数从环境变量加载多个模型,按优先级排序:

go
func NewScreenshotHandler(db *gorm.DB) *ScreenshotHandler {
    models := map[string]visionModel{}

    // 智谱 GLM(默认/兜底)
    if key := os.Getenv("ZHIPU_API_KEY"); key != "" {
        models["glm"] = visionModel{
            ID:      "glm",
            Model:   "glm-4.6v-flash",
            APIKey:  key,
            APIBase: "https://open.bigmodel.cn/api/paas/v4",
        }
    }

    // MiniMax M3(主力,精度更高)
    if key := os.Getenv("MINIMAX_API_KEY"); key != "" {
        models["minimax"] = visionModel{
            ID:      "minimax",
            Model:   "MiniMax-M3",
            APIKey:  key,
            APIBase: "https://api.minimaxi.com/v1",
        }
    }

    return &ScreenshotHandler{models: models, httpClient: &http.Client{Timeout: 120 * time.Second}}
}

关键设计:两个模型来自不同供应商,审核策略独立。MiniMax 误判的图,GLM 通常能正常处理,反之亦然。

实现:内容审核拒绝检测

go
// isContentRejected 判断错误是否来自模型侧内容审核(如 422 "sensitive")
// 这类错误重试无意义,必须切换到另一个模型
func isContentRejected(err error) bool {
    if err == nil {
        return false
    }
    msg := err.Error()
    return strings.Contains(msg, "sensitive") || strings.Contains(msg, "422")
}

在 HTTP 响应处理中,422 被标记为不可重试(不同于 429/5xx):

go
if resp.StatusCode != http.StatusOK {
    msg := fmt.Errorf("vision model returned status %d: %s", resp.StatusCode, truncate(string(respBody), 200))
    if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
        return parseResult{}, retryableError{msg} // 可重试
    }
    return parseResult{}, msg // 不可重试(4xx,含 422)
}

实现:指数退避重试

可重试错误走指数退避,避免狂打免费模型的 API:

go
func callVisionWithRetry(ctx context.Context, vm visionModel, imgBytes []byte) (parseResult, error) {
    const maxAttempts = 5
    backoffs := []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second}

    for attempt := 0; attempt < maxAttempts; attempt++ {
        result, err := callVision(ctx, vm, imgBytes)
        if err == nil {
            return result, nil
        }

        var re retryableError
        if !errors.As(err, &re) {
            return parseResult{}, err // 不可重试,立即返回
        }

        if attempt < maxAttempts-1 {
            log.Printf("attempt %d failed (retryable), retrying in %v", attempt+1, backoffs[attempt])
            select {
            case <-ctx.Done():
                return parseResult{}, ctx.Err()
            case <-time.After(backoffs[attempt]):
            }
        }
    }
    return parseResult{}, fmt.Errorf("vision model unavailable after %d attempts", maxAttempts)
}

退避序列 5s → 10s → 20s → 40s,总等待时间 75 秒。免费模型的限流窗口通常在 60 秒内恢复。

实现:模型 fallback

这是最关键的一层。主模型因内容审核被拒时,自动遍历其他模型:

go
func callVisionWithFallback(ctx context.Context, vm visionModel, imgBytes []byte) (parseResult, string, error) {
    // 尝试首选模型
    result, err := callVisionWithRetry(ctx, vm, imgBytes)
    if err == nil {
        return result, vm.Label, nil
    }

    // 内容审核拒绝 → 换模型
    if !isContentRejected(err) {
        return parseResult{}, "", err // 其他错误不 fallback
    }

    for _, fallback := range availableModels() {
        if fallback.ID == vm.ID {
            continue
        }
        log.Printf("model %s rejected image, falling back to %s", vm.ID, fallback.ID)
        result, err = callVisionWithRetry(ctx, fallback, imgBytes)
        if err == nil {
            return result, fallback.Label, nil
        }
    }
    return parseResult{}, "", err
}

只有 isContentRejected 返回 true 时才触发 fallback。普通的 429/5xx 在重试层就消化掉了,不会浪费 fallback 的额度。

JSON 解析的容错处理

不同模型返回的 JSON 格式可能不一致。GLM 有时会用 XML 标签或 markdown 代码块包裹 JSON:

go
func parseVisionJSON(content string) (parseResult, error) {
    var result parseResult
    cleaned := strings.TrimSpace(content)

    // 去掉 markdown 代码围栏
    cleaned = strings.TrimPrefix(cleaned, "```json")
    cleaned = strings.TrimPrefix(cleaned, "```")
    cleaned = strings.TrimSuffix(cleaned, "```")
    cleaned = strings.TrimSpace(cleaned)

    // 先尝试直接解析
    if err := json.Unmarshal([]byte(cleaned), &result); err == nil {
        return result, nil
    }

    // fallback:提取最外层 { ... } 块
    firstBrace := strings.Index(cleaned, "{")
    if firstBrace < 0 {
        return result, fmt.Errorf("no JSON object found")
    }
    sub := cleaned[firstBrace:]
    return result, json.Unmarshal([]byte(sub), &result)
}

这段代码处理三种异常输出:

  1. Markdown 代码围栏包裹(```json ... ```
  2. XML/HTML 标签包裹(<answer>...</answer>
  3. JSON 前后有多余文字(模型输出了解释性文字)

调试经验

实测中发现的一个规律:MiniMax 对含"人民币账户 CNY A股""仓位 66.5%"等汇总行的截图拦截率接近 100%。去掉截图顶部 15-25% 的账户摘要区域后,同一张图就能正常通过。

这说明内容审核模型对"看起来像完整财务凭证"的图像有确定性拦截规则。如果业务允许裁剪,在客户端预处理是比 fallback 更省 token 的方案。但如果不能裁剪(用户需要完整截图解析),fallback 链是唯一可靠的选择。

小结

错误类型HTTP 状态码处理策略层级
限流429指数退避重试callVisionWithRetry
服务器错误5xx指数退避重试callVisionWithRetry
网络超时-指数退避重试callVisionWithRetry
内容审核拒绝422切换备用模型callVisionWithFallback
认证/请求错误400/401/403直接返回callVision

核心设计原则:不同类型的错误走不同路径,不要混在一起重试。422 重试十次也不会通过,但换一个模型可能第一次就成功。

核心设计原则:不同类型的错误走不同路径,不要混在一起重试。422 重试十次也不会通过,但换一个模型可能第一次就成功。