用同一个API key和chat接口接入多个模型的降级方案,关键是把「报告解析成功」而非「HTTP 200」作为成功标准,配合严格JSON Schema校验和显式回退策略。
Short answer:对于对 moderation reports 进行分类的 SaaS 聊天机器人,选择一个可以访问多个模型选项的聊天 API 和密钥,然后在应用中放入严格的 JSON Schema 验证器和明确的 fallback 策略。Provider 的响应只有在报告解析成功、匹配 schema、通过领域检查后,才能算作成功。
运营目标不仅仅是 HTTP 200。而是要交付一个可用的 moderation 决策,且仅交付一次,同时附带足够的证据让人工审核员理解报告被路由的原因。先从 chat completions 和模型发现开始;在你还没有能力衡量这条窄路径之前,不要去构建一个通用的路由平台。
我的 runbook 偏见来自 missed jobs 和重复投递的页面:重试是状态转换,不是网络细节。同样的反射也适用于此。超时、HTTP 429、格式错误的 JSON、或有效但不可能的标签,都可能触发另一次尝试,但只有一条被接受的分类才能进入审核队列。
报告契约是一条治理边界
先选控制面,再选最喜欢的模型。对于这个工作负载,控制面必须能够发现可用的模型 ID、通过一个聊天 API 发送相同的结构化请求,且在不改认证或响应处理的情况下切换到下一个获批的模型。这才是一个密钥背后 fallback 的有用含义。它不是把每个报告都喷洒到各个 provider 上。Moderation 契约由应用所有,包括存在哪些标签、什么内容达到审核员、以及保留什么证据。
没有通用的赢家。Direct APIs 与各模型 vendor 保持最干净的关系。LiteLLM 给平台团队更多控制权,但代价是增加一个生产服务。托管式多模型运行时减少了集成和账户开销,但也把路由和目录可用性移到了外部控制平面。不同团队的 on-call 深度和合规要求,适用程度也不同。
在启用生产 fallback 之前做一下每个候选的成本估算。成本应该和正确性、容量一起放在策略里,而不是放在开场 pitch 里,也不是作为事后惊喜。
SaaS 聊天机器人 API 应该对 fallback 模型有什么要求?
报告分类器需要一个刻意小的输出契约。例如,只接受 spam、harassment、self_harm 或 other;从 0 到 1 要求提供 confidence;为审核员要求一个简短的理由。这个运行时没有专用的 moderation 端点,所以预期的机制是带有 json_schema 输出的聊天模型加上本地验证。Schema 收窄了生成过程。验证器决定接受与否。
这个区别很重要,因为语法上有效的 JSON 仍然可能在操作上是错误的。模型可能输出 abuse,听起来合理但不是审核系统认识的队列。它可能返回 confidence 1.4。它可能在对象外面加了说明文字。每种情况都应该消耗一次尝试并可能转向下一个候选;都不应该创建审核记录。
用 rpt_2048 走过边界。主模型返回 HTTP 200,内容为 {"report_id":"rpt_2048","category":"abuse","confidence":0.91,"reason":"threatening language"}。JSON 解码成功,但 category 超出了四值契约,所以这次尝试被记录为领域拒绝,什么都不写入。下一个候选返回相同报告 ID,category 为 harassment,confidence 为 0.88;验证成功,队列写入器认领了从 rpt_2048 派生的幂等键。现在假设主模型的响应在 fallback 已经提交之后才到达。它仍然无法创建第二条审核记录,因为在模型边界接受和在写入边界做幂等是两次独立的检查。这就是为什么"出错时尝试另一个模型"对 runbook 来说太过模糊:团队必须定义错误、定义接受、以及单一持久的状态转换。
在遥测中保持失败类分离:传输失败、速率限制、无效 JSON、Schema 拒绝、领域拒绝。HTTP 429 应该遵循 Retry-After 头(如果存在的话),然后使用指数退避。Schema 拒绝通常应该转向下一个获批模型,而不是无限询问同一个模型。在写入边界使用稳定的报告 ID 作为幂等键,这样晚到的第一次尝试和成功的 fallback 不会将两个分类入队。
在接受规则之后集成发现
不要凭记忆输入模型 ID。以下 Go 程序针对已验证的 model-discovery 路由执行第一次生产检查,使用必需的 bearer 密钥,显式设置 HTTP 方法,暴露非成功响应体,并在没有紧循环的情况下处理 429。将 INFRAI_BASE_URL 设为账户的 API base URL;放在配置中也使得这个未关联的示例在 staging 环境中可用,而不必把 host 嵌入源码。
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type model struct {
ID string `json:"id"`
Available bool `json:"available"`
Capability string `json:"capability"`
}
type catalog struct {
Count int `json:"count"`
Data []model `json:"data"`
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
models, err := getModels(ctx, os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY"))
if err != nil {
panic(err)
}
for _, m := range models.Data {
if m.Available && m.Capability == "chat" {
fmt.Println(m.ID)
}
}
}
func getModels(ctx context.Context, baseURL, key string) (catalog, error) {
if baseURL == "" || key == "" {
return catalog{}, fmt.Errorf("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(baseURL, "/")+"/ai/models", nil)
if err != nil {
return catalog{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
lastErr = err
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return catalog{}, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return catalog{}, ctx.Err()
case <-time.After(delay):
lastErr = fmt.Errorf("rate limited")
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return catalog{}, fmt.Errorf("model discovery status %d: %s", resp.StatusCode, body)
}
var result catalog
if err := json.Unmarshal(body, &result); err != nil {
return catalog{}, err
}
return result, nil
}
return catalog{}, fmt.Errorf("model discovery retries exhausted: %w", lastErr)
}
从输出中选择三个获批的 chat ID,并将它们的顺序存为版本化配置。请求路径则保持在 OpenAI 兼容的 chat-completions 表面上:对每个候选按顺序提交相同的 messages 和严格的 schema。运行时提供可达性;部署提供策略。
接受逻辑在 fallback 循环内部。解码到包含 report_id、category、confidence 和 reason 的 Go struct;拒绝未知字段;要求原始报告 ID;检查四个允许的 category;并在 0 到 1 的闭区间强制执行 confidence。只有在那之后才持久化分类。如果解码或验证失败,记录失败类并尝试下一个获批的候选。如果三个都失败了,将未改变的报告路由到人工审核。
常见错误是把解析成功计为分类成功。不要这样做。如果下游队列接受四个 category 值,模型必须产出这四个值之一。
在 shadow traffic 中测量拒绝模式
从一个脱敏的 moderation reports 集合构建固定评估集,并在每个条目旁边保留预期的路由结果。Gate 应该覆盖精确的 schema 合规性、允许的 category、report-ID 保留、以及随后的人工审核处置。不要因为候选返回了精美的解释就晋升它。晋升它是因为它在重要的案例上与主路径满足了相同的契约。
从 shadow 模式开始:不将从 fallback 候选的调用允许入队,将其输出与当前路径比较,并记录拒绝原因。我会按模型检查尝试次数、schema 拒绝率、领域拒绝率、429 计数、接受的 fallback 深度、重复写入抑制。避免一个发明的综合可靠性分数。原始计数器给安慰性解读留下的空间更少。
然后执行三个受控案例。首先,让主调用返回速率限制结果,并验证下一次尝试之前发生了退避。其次,返回未知标签 abuse,并验证在没有有效 fallback 到达之前没有审核项目被写入。第三,向队列边界两次投递被接受的结果,并验证稳定的报告 ID 产生一个状态转换。精确的阈值取决于报告构成,而且我不确定一个通用的 confidence cutoff 是站得住脚的;用带标签的数据和审核员结果来解决,而不是用 vendor 默认值。
还有一个容易忽略的检查:计算最大尝试次数和墙上时钟预算。三个候选加上无限制重试可以把一个快速分类器变成慢的。给每次尝试一个截止日期,限制总尝试次数,并将耗尽的报告路由到人工审核,而不是去猜测。
将候选顺序 rollout 为版本化策略
将上一个获批的候选列表保持为版本化配置。如果 schema 拒绝、速率限制或审核员分歧超过了团队自己的告警阈值,就停止向变更后的策略分配新流量,并恢复该列表。已经接受的分类仍然是可审计的记录;在回滚期间不要重写它们。只有重新入队从未达到接受终态的报告,使用原始报告 ID 以保持写入的幂等性。
问题是,单密钥运行时在策略要求直接契约、provider 特定控制或自托管路由时并不适合。当 vendor 原生行为是产品需求时,坚持使用相关的一方 API。当同一个工作流必须包含 ASR、实时语音会话或超过 Lanczos 的图像放大时,也选择另一个服务边界;这些能力超出了本文描述的有用范围。
Fallback 是一个遏制机制,不是降低门槛的许可。如果每个候选都违反契约,就向人工审核关闭地失败。
OpenAI API reference: https://platform.openai.com/docs/api-reference/chat
Anthropic Messages API: https://docs.anthropic.com/en/api/messages
Gemini API documentation: https://ai.google.dev/gemini-api/docs
LiteLLM source and documentation: https://github.com/BerriAI/litellm
JSON Schema 2020-12: https://json-schema.org/draft/2020-12
Go error handling: https://go.dev/blog/error-handling-and-go
Go context package: https://pkg.go.dev/context
For further actions, you may consider blocking this person and/or reporting abuse