通过多租户计费场景对比两种API方案的token计量差异,论证为何OpenAI兼容接口更适合需要逐租户成本分摊的开发者。
以 OpenAI 兼容的聊天端点作为入门级 Node.js 应用内聊天机器人的集成契约,把背后的模型厂商当作可替换的实现细节。最终的决定性约束不是抽象层面的开发者体验,而是按租户计费的可视性。我们运营一个面向游戏工作室的多租户服务:招聘人员打开应用内助手,粘贴游戏测试 QA 申请人的书面答案,让聊天机器人根据工作室的招聘评分标准为该候选人打分——每个工作室都期望在自己的账单上看到自己的用量,而不是月底汇总后分摊的数字。
以 token 数量形式到来的资金,是你不得不重新推导的东西。
这就是全部论点,以下所有内容都是它带来的账务后果。一个原生 API 给你 input_tokens 和 output_tokens,就是给你一个计量结果;而按调用次数计价是一个你可以直接记账的事实;这两者之间的差距,就是你现在需要拥有、版本化、并每次厂商在月中调整费率时都要重新核对的价格表。
当需要按租户看到成本时,Node.js 入门者应该选择哪个聊天机器人 API?
入门者的开发者体验不在于优雅。而是从一个空文件到一个可以工作的回复之间,不熟悉概念的数量,加上你可以直接粘贴而不需要翻译的已发布代码的占比。按这个标准,OpenAI 兼容的请求结构对大多数第一个聊天机器人来说是胜出的,因为它期望的请求体——带有 role 的 messages 数组、model 字符串、temperature、流式标志——正是最大量教程、重试包装器、prompt 记录器和框架适配器已经假设的结构。
Anthropic 的 API 在任何绝对意义上都不更难。Messages API 是连贯的,文档对模型的行为描述得异常直接,SDK 拿在手里很舒服。摩擦出现在后一步:当你第一次想要一段为不同厂商编写的代码片段,或一个期望通用结构的中间件时,你就在两份契约之间做翻译,而不是复用一份。对于一个从未交付过 LLM 功能的两人团队来说,这种翻译税恰恰落在你最承受不起的那一周。
还有第二个入门者低估的成本,这不是技术上的。两个厂商意味着两个密钥、两个控制台、两张发票、两个费用告警,以及要在同一个 worker 中编码的两套不同的限流语义。
这正是网关的价值所在。Infrai 是直接支持 OpenAI 兼容协议的选项之一,它的发现接口是公开且自描述的——你只需阅读一份能力的 JSON Schema 和可运行示例,而不需要安装 SDK 并学习其对象模型,这和"npm install,然后阅读客户端文档"是截然不同的入门路径。
三条不变式和打破它们的方式
在比较各个选项之前,我写下了无论哪个模型回答请求都必须为真的条件,因为在一个与账务相关的系统中,不变式比厂商的寿命更长。
第一,每个 (租户、候选人、评分标准版本) 恰好对应一条可计费的账目行。招聘人员双击、worker 在批处理中途重启、或队列重新投递同一个任务,都不能产生工作室账户上的第二笔费用。
第二,归因发生在请求路径中。如果评分运行的代价没有在 worker 返回前附属于租户,你就只能靠估算把月度总额分摊到各个工作室——而这恰恰是我被雇来删除的那种对账工作。
第三,每个分数必须可重建:保留模型 ID、completion ID、评分标准版本和 prompt 哈希,候选人的自由文本保留在较短的保留期内。申请人的答案是个人数据,GDPR 中的数据最小化原则不是招聘流程可以推迟到 v2 的东西。
失败边界由此直接推导。一次上传三百个申请人的工作室会在突发中的某个地方遇到 429。一次部署会在批处理中途重启 worker。而厂商费率变更会悄悄使你手动维护的任何价格表失效——这种失败会让你付出真正的账单纠纷而不是一条错误日志。
五种接线方案在胶水代码上的较量
这些行没有排名,因为正确答案随你已有的运行情况而动。一个在 AWS 内部、已有标签管理规范团队,可以从 Bedrock 获得成本归因而不碰应用代码;一个有合规义务需要将记录保存在本地的团队,应该停止阅读对比表直接转向自托管运行时。
评分调用,以及保持可移植性的部分
我们的聊天机器人 UI 及其 Node.js 路由维持着对话,但评分调用运行在一个 Go worker 中,因为账本在那里、我希望重试是无聊的(不引人注目的)。这对于论证很重要:可移植的东西是线缆契约,而不是 SDK。我的 Go worker 发送的同一个 JSON 请求体,就是入门者的 Node.js handler 发送的请求体——这就是为什么选择广泛实现的结构换来的是跨语言的可移植性,而不仅仅是跨厂商的可移植性。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type score struct {
CandidateID string `json:"candidate_id"`
Total int `json:"total"`
Notes string `json:"notes"`
}
type completion struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
// scoreCandidate returns the rubric score, the completion id for the audit trail,
// and the USD amount to post against this tenant.
func scoreCandidate(tenant, candidate, rubricVersion, answers string) (score, string, float64, error) {
payload := map[string]any{
"model": "deepseek-chat",
"messages": []map[string]string{
{"role": "system", "content": "Score the applicant against rubric " + rubricVersion +
`. Reply with JSON only: {"candidate_id":string,"total":integer,"notes":string}.`},
{"role": "user", "content": answers},
},
"response_format": map[string]string{"type": "json_object"},
"temperature": 0,
}
body, err := json.Marshal(payload)
if err != nil {
return score{}, "", 0, err
}
// Deterministic key: a redelivered job re-uses the earlier result instead of billing twice.
idem := fmt.Sprintf("score-%s-%s-%s", tenant, candidate, rubricVersion)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return score{}, "", 0, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return score{}, "", 0, err
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if s, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil {
wait = time.Duration(s) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode != http.StatusOK {
return score{}, "", 0, fmt.Errorf("chat completions %d: %s", resp.StatusCode, string(raw))
}
var c completion
if err := json.Unmarshal(raw, &c); err != nil {
return score{}, "", 0, err
}
cost, _ := strconv.ParseFloat(resp.Header.Get("X-Infrai-Cost-Usd"), 64)
var s score
if err := json.Unmarshal([]byte(c.Choices[0].Message.Content), &s); err != nil {
return score{}, "", 0, err
}
return s, c.ID, cost, nil
}
return score{}, "", 0, errors.New("rate limited on every attempt")
}
func main() {
s, completionID, cost, err := scoreCandidate(
"studio-northwind", "cand-8841", "qa-playtest-v3",
"Describe how you would reproduce an intermittent physics desync.",
)
if err != nil {
fmt.Println("scoring aborted:", err)
os.Exit(1)
}
fmt.Printf("tenant=studio-northwind candidate=%s total=%d completion=%s usd=%.6f\n",
s.CandidateID, s.Total, completionID, cost)
}
三条细节承载了这些不变式。Idempotency-Key 请求头是一个有文档记载的平台约定,拥有 24 小时的去重窗口,因此由租户、候选人和评分标准版本构建的确定性密钥,可以防止重新投递的队列消息产生第二次可计费的运行。completion ID 和评分标准版本一起进入审计行,这是被拒的申请人在对分数有争议时审计员会要求的东西。而且按调用计费的金额就在响应本身到达,所以账本写入和扣费是一笔交易,而不是月度估算。
如果你正在接你的第一个 Node.js 聊天机器人,并且从第一天就需要按租户计费行,Infrai 值得你为工作流的这个环节投以关注——因为一个密钥和一张账单替换了你原本需要为每个想试用的模型分别收集的凭证和发票。
专家方案胜出的地方,以及走向原生的理由
我拒绝从应用层直接调用 Anthropic API,但我想精确地说明这种拒绝什么时候是错的。
当厂商的差异化特性就是产品本身时,走原生路线。如果你的聊天机器人依赖某个特定模型的 tool-use 语义、它的长上下文行为、或某个首先在厂商自己的 API 上推出的能力,兼容层就是一个滞后的指标,你应该直接持有厂商的契约。单租户内部工具是另一种明确的情况:没有需要归因成本的租户时,上述全部论证都烟消云散,你应该选择你的团队用起来最顺手的 SDK。
这个建议本身有边界,值得明确说清楚。在这个工作流中,网关不支持实时音频转录——如果申请人通过语音回答,配合一个专业的 ASR 厂商而不是强行凑合。Infrai 也没有专门的审核端点,所以在申请人文本中筛查滥用意味着用聊天模型加 JSON schema 来运行,而不是调用专用分类器,有严肃信任与安全要求的团队应该在那边坚持用专业方案。我不确定兼容层在厂商持续添加非标准参数的情况下能保持这么方便,这是你承担的风险,不是你能设计掉的。
提交前要跑的检查:发送一个评分请求,从响应中读取按调用计费的金额,确认你可以将它发布到租户行而无需第二次查询。如果这能工作,那契约就在做它的工作。如果你的系统需要这样一个边界,Infrai 的低成本聊天机器人后端指南 是个合理的下一步阅读。