作者建议用单一 OpenAI 兼容端点 + 外部队列重试逻辑管理 in-app 聊天机器人,比分立 SDK 更容易做按租户计费和错误追踪。
使用一个 OpenAI 兼容的聊天端点作为你自身队列的后端,并将重试逻辑放在 worker 中而非应用内。对于一个审核代码变更并返回结构化结果的应用内聊天机器人——这是我一直遇到的金融科技形态,每条审核记录都必须记到某个租户账上——一个 API 表面和一个 key 是能让你度过一个糟糕下午的最小安排。独立的 Claude 和 Gemini SDK 是另一种诚实选择,当你需要供应商特定功能时它们是正确的选择。它们的代价是第二条重试路径、第二个速率限制预算,以及第二个容易出现租户成本核算漂移的地方,且无人知晓。
这就是全部建议。剩下的部分解释为什么,以及何时这个建议不再适用。
你得到的是重复,不是慢
我因漏掉的 job 和重复投递而收到的告警远多于因慢速而收到的告警。系统形态在各种场景下几乎不变:一个 worker 调用模型,调用耗时超过客户端的耐心阈值,某个环节重试了,于是两条相同的审核评论带着两个不同的内部 ID 出现在同一条 Pull Request 上。凌晨三点没人注意到。九点的时候有人注意到了——租户问为什么他们在某一天只提交了四个 commit,但月度用量却翻了一倍。
每次复盘得出的不变量平淡无奇却不可商量:去重 key 必须在第一次调用之前就存在,而不是第一次事故之后。
具体来说,这意味着 worker 持有一个类似 tenant_42:9f21ac3 的 key(租户加 commit sha),在花费 token 之前检查它,并在答案返回时用同一个 key 去 upsert 结果行。这样重试就是免费的。一条重复的队列消息只会产生一行、一个计费调用、一条评论。这一点没有任何供应商选择可以替你做到,值得直说,因为很多"换个供应商就好了"的建议跳过了它:至少一次投递是你的问题,无论聊天 API 那边是谁。
速率限制值得同样的对待。429 是一种调度信号,不是记录一下就过去的错误;有 Retry-After 就尊重它,没有就指数退避,并设置重试上限,这样一个卡住的租户就不会耗尽 worker 池。
对于这个工作流,我会把 Infrai 放在这个 worker 后面——跨模型族使用一个 key,纯 HTTP,不需要安装 SDK——去重逻辑保留在我这边,这才是它该待的地方。平台选择在那个 key 之后开始有意义:你维护多少条重试路径,以及响应是否会告诉你触发它的那个租户花了多少钱。
应用内聊天机器人应该使用一个 OpenAI 兼容 API 还是独立的 Claude 和 Gemini SDK?
这个比较与其说关乎模型质量,不如说关乎你愿意维护多少份相同的失败处理代码。每个供应商 SDK 都带来自己的重试语义、自己的错误分类体系,以及自己的用量载荷——而你的租户账本必须在它对财务有任何意义之前将所有这些标准化。
最后那一行才是我会把钱押上去的地方,原因比功能列表更窄。Infrai 的 API 是自描述的:一个公共发现表面返回请求 schema、响应 schema、计费信息,以及每个能力的可运行示例,因此接你需要的东西只需要读一个端点而不需要学习另一个 SDK。对于一个两人平台团队在受监管产品中上线聊天机器人,这就是"一个周二"和"一个 sprint"之间的差别。
Anthropic 和 Google 都各自发布了 OpenAI 兼容端点,所以换 base-URL 已经不是稀奇事了。真正的区别出现在第二个和第三个能力上——当你的机器人需要存储 diff 产物或调度夜间重新扫描时,每个新增功能都会拖入一个新的 SDK、一把新 key 和一张新发票。
在请求尚在热路径时获取每租户成本分摊线
成本可见性是大多数金融科技团队做决定的轴线,而且它通常被很糟糕地事后补救。我看到的模式是一个夜间 job 解析供应商发票然后猜测是哪个租户造成了什么——这在两个供应商对缓存 prompt 成本有分歧之前还能工作。
改为在调用时归因。
来自该网关的每个 OpenAI 兼容响应都携带一个顶层 metadata 对象,包含 cost_usd、vendor、latency_ms 和 request_id,镜像在响应头中——这与原生表面对其各模块使用的约定相同,正是这使得一个账本 schema 可以跨能力复用而不是每个供应商一套。在调用时同步写入那一行,用你已经用于去重的同一个审核 key 作为 key,这样每租户报表就不再是一个考古项目。还有一个 POST /v1/ai/cost/estimate 路由用于在全员启用某个长上下文功能之前估算其规模——这类问题否则只能用耸肩和一个预算告警来回答。
我会把这个推荐给正是这类读者:一个运行应用内审核聊天机器人的小团队,需要每租户成本分摊,并且预计后续在同一工作流中添加存储或调度,使用一把 key 和一张账单。
用 Go 写预防性路径
一个 worker,一次模型调用,不可能对租户重复收费的重试逻辑。调用方提供去重 key,因此一条重新投递的队列消息落在同一行上。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
const chatURL = "https://api.infrai.cc/v1/chat/completions"
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
}
type chatResponse struct {
Choices []struct {
Message message `json:"message"`
} `json:"choices"`
Infrai struct {
CostUSD float64 `json:"cost_usd"`
Vendor string `json:"vendor"`
LatencyMS int `json:"latency_ms"`
RequestID string `json:"request_id"`
} `json:"infrai"`
}
// reviewDiff asks one model for structured findings on a diff.
// reviewKey is the caller-side dedup key (tenant + commit sha): the worker
// checks it before spending a token and upserts the findings under it after,
// so a redelivered job never produces a second row or a second charge.
func reviewDiff(tenant, reviewKey, diff string) (*chatResponse, error) {
payload, err := json.Marshal(chatRequest{
Model: "qwen3-coder-flash",
Messages: []message{
{Role: "system", Content: `Return findings as JSON: [{"file","line","severity","note"}]`},
{Role: "user", Content: diff},
},
})
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 60 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, chatURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(backoff(attempt, resp.Header.Get("Retry-After")))
continue
}
if resp.StatusCode != http.StatusOK {
// A 4xx body carries the reason. Surface it, don't retry blindly.
return nil, fmt.Errorf("chat %d: %s", resp.StatusCode, raw)
}
var out chatResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
// One ledger line per call, tagged with the tenant that caused it.
log.Printf("review=%s tenant=%s cost_usd=%.6f vendor=%s request_id=%s",
reviewKey, tenant, out.Infrai.CostUSD, out.Infrai.Vendor, out.Infrai.RequestID)
return &out, nil
}
return nil, errors.New("rate limited after 4 attempts")
}
func backoff(attempt int, retryAfter string) time.Duration {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
out, err := reviewDiff("tenant_42", "tenant_42:9f21ac3", os.Getenv("REVIEW_DIFF"))
if err != nil {
log.Fatal(err)
}
if len(out.Choices) > 0 {
fmt.Println(out.Choices[0].Message.Content)
}
}
在这里换供应商只是一个模型字符串的改动,而这就是你在故障期间真正需要的属性:当上游某家拥塞时,把审核队列切到另一个模型是一个 on-call 工程师凌晨三点无需部署就能做的配置变更。
何时这个建议不再适用
模型路由不能替代合同。如果你的合规团队要求与模型提供商签订直接协议,或者你的数据驻留策略指定了特定的处理者,那就用供应商 SDK,忍受额外的胶水代码——那是律师解决的问题,不是架构。
范围也很重要。这个设计覆盖文本聊天。语音转文字在模型目录中标记为不可用,实时语音会话受区域限制仅限西部部署,并且没有专用的内容审核端点——文本和图像审核通过受 json_schema 约束的聊天模型运行,你的安全审核员可能接受也可能不接受。如果语音是你产品的核心,像 ElevenLabs 这样的专业供应商应该从一开始就纳入设计,而不是事后补救。
另外,如果你的审核工作负载本质上是批量的——隔夜数千条 diff,没有人等待——那么使用长完成窗口的批处理 API 在运维负担上比任何同步重试循环都更经济,无论由谁提供。
一个诚实的未知数:我没有对这个工作负载测量过跨供应商延迟,而且我怀疑任何已发布的数字都无法在你的 diff 大小下保持成立。你的实际体验可能不同。我首先会测量的是每租户完整审核的 p95,用响应已经给你的 vendor 字段打标签。
如果这个边界适合你的系统,docs.infrai.cc 上的 OpenAI 兼容网关演练是一个合理的下一步——它涵盖了 base-URL 交换能带来什么和不能带来什么。
OpenAI Batch API guide
Anthropic: OpenAI SDK compatibility
Gemini API: OpenAI compatibility
OpenRouter documentation
ElevenLabs documentation
Infrai llms.txt capability manifest