Novita AI 接入:base_url 没有 /v1 后缀
我上一个项目卡在一个很土的决策上:需求还没验证,到底是先租机器把模型跑起来,还是先调别人的 API 把链路跑通。这两条路的沉没成本完全不一样——自建先付出的是运维、镜像、显存排布的时间,API 先付出的是调用费和平台绑定风险。
Novita 的位置比较特别:它既卖模型 API,也提供 GPU 资源。对开发者来说,这意味着不必在第一天就选边站。前期用 OpenAI 兼容端点把产品逻辑、prompt、评测集跑顺,等 QPS 和上下文长度稳下来,再拿真实账单算自建划不划算,那时候的判断才有依据。GPU 的具体型号与价格以官网为准,本文只写 API 接入这一半;自建那条路的成本结构见 算力云与 GPU 租用。
下面是我实际接入的三步,以及最容易翻车的地方。
一、base_url:它真的没有 /v1
Novita 的 OpenAI 兼容端点是:
https://api.novita.ai/openai
看清楚结尾——是 /openai,后面没有 /v1。官方的说法很直接:把 base URL 设成 api.novita.ai/openai,就能开始用它的 LLM 服务,跟你现有的 OpenAI 集成完全兼容。
这一点和多数 OpenAI 兼容平台的习惯不一样。业界大多数端点长成 .../v1 结尾的样子,久而久之,很多人的肌肉记忆和很多二次封装的默认行为都是「帮你补 /v1」。抄错的后果不是一个友好的报错,而是 404——一个足够含糊、能让人怀疑人生的状态码。
翻车通常发生在这两类地方:
一是你自己拼路径。 有人习惯把「服务商根地址」存进配置,代码里写 f"{BASE}/v1/chat/completions"。这写法在别的平台跑了两年没出过问题,换到 Novita 直接 404。
二是二次封装帮你拼。 各类 API 网关、桌面客户端、自研 SDK wrapper 里,经常有一个叫「服务地址」的配置项,说明写着「填不含 /v1 的根地址」,然后内部自动补。你老老实实填 https://api.novita.ai/openai,它拼出来就是 https://api.novita.ai/openai/v1/chat/completions,多了一段。这种 bug 最难查,因为你在配置界面上看到的字符串是完全正确的。
官方的 OpenAI Python SDK 属于「不多事」的那一类:你传进去的 base_url 就是前缀,相对路径直接往后接,所以传 https://api.novita.ai/openai,最终打到的是 https://api.novita.ai/openai/chat/completions,正是我们要的形态。
怎么确认自己拼对了?我一般用三个办法,从便宜到贵:
办法一,先用 curl 打通再说。 别一上来就在框架里调,命令行确认一遍路径形态,后面所有排查都有了基准线。
办法二,把实际请求 URL 打出来。 httpx 的事件钩子、SDK 自带的 debug 日志、最土的抓包都行,只要能看到最终那条完整 URL。凡是配置项经过了两层以上封装,这一步就值得做。
办法三,用一个故意写错的 key 试探。 拿一串垃圾字符当 key 打一次:回 401 说明请求已经走到鉴权环节,路径大概率是对的;回 404 则多半是路径没落在任何已注册的路由上。这个小动作比盯着字符串数斜杠可靠。中间若隔着自建网关,行为会被网关改写,这条经验要打折扣。
再补一条配置卫生:把 base_url 当成唯一真源存在环境变量里,结尾别留斜杠,代码里任何地方都不要再手工拼版本段。这类问题的通用治理方式我在 base_url 怎么改 里写过,那篇讲的原则套在 Novita 上一样成立,只是这里的坑更隐蔽一点。
二、鉴权与 key 管理
鉴权是标准的 Bearer 形式:
Authorization: Bearer ${API_KEY}
key 在这个页面签发和管理:https://novita.ai/settings/key-management。
官方文档里写的占位符就是 ${API_KEY},并没有规定一个专属的环境变量名(不像有些平台会强推自己的变量名)。这反而是好事——你可以按自己的命名规范来。我一般写成 NOVITA_API_KEY,前缀带平台名,多平台并存的时候不会串。要提醒的是,既然名字是你自己起的,就更要在 README 和部署脚本里把它写清楚,否则换个人接手,光找这个变量叫什么就要半小时。
key 本身的纪律没什么新鲜的,但每一条都是拿事故换来的:按环境分发不同的 key,别让本地调试的 key 出现在生产镜像里;key 只存在环境变量或密钥管理服务里,不进代码、不进前端、不进日志;轮换要能不停服完成,也就是说代码里读 key 的地方必须只有一处。完整做法参考 API key 管理。
三、模型 ID 与第一个请求
Novita 的模型 ID 是 组织/模型名 这种风格,文档里的示例是:
deepseek/deepseek-r1
这套命名对熟悉开源模型生态的人很友好,但也有个副作用:斜杠是模型名的一部分,不是路径分隔符。有人把它当路径拼进 URL 里,或者在某些配置文件里被 YAML 解析成了别的东西,都出过问题。它就是一个普通的字符串字段,原样塞进 model 参数即可。
Python 侧用官方 OpenAI SDK:
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["NOVITA_API_KEY"],
base_url="https://api.novita.ai/openai", # 注意:没有 /v1
)
resp = client.chat.completions.create(
model="deepseek/deepseek-r1",
messages=[
{"role": "user", "content": "用两句话解释什么是向量数据库"},
],
)
print(resp.choices[0].message.content)
对应的 curl,建议第一次接入时先跑这个:
curl https://api.novita.ai/openai/chat/completions \
-H "Authorization: Bearer $NOVITA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek/deepseek-r1",
"messages": [
{"role": "user", "content": "用两句话解释什么是向量数据库"}
]
}'
curl 这条通了,SDK 那条还不通,问题就一定在客户端配置上,不用再怀疑账号和网络。这个分而治之的顺序能省掉大量无效排查。SDK 层的通用写法可以对照 用 OpenAI SDK 接入兼容平台,这里不重复。
四、计费:价格表,以及表里藏的两个工程问题
官方定价页上的价格如下(/Mt 表示每百万 token):
| 模型 | 输入 | 输出 |
|---|---|---|
| Deepseek V4 Flash 0731 | $0.14 /Mt | $0.28 /Mt |
| Deepseek V4 Flash | $0.14 /Mt | $0.28 /Mt |
| Deepseek V4 Pro | $1.6 /Mt | $3.2 /Mt |
| Deepseek V3.2 | $0.269 /Mt | $0.4 /Mt |
| DeepSeek-OCR 2 | $0.03 /Mt | $0.03 /Mt |
价格会变,上表以官方定价页为准,别把它硬编码进你的成本模型里。
带日期后缀的那一行,是让你做选择的
注意表里的头两行:Deepseek V4 Flash 0731 和 Deepseek V4 Flash,输入输出价格完全一样。带日期后缀的那个是版本快照,不带后缀的是滚动别名,会随着平台升级指向新的权重。
同价这件事本身就是一个信号:钉住某个具体版本不用多花钱。也就是说,选哪个不是成本问题,是纯粹的工程问题,而且是一个必须提前决定、事后很难补救的问题。
钉版本(用 0731 这类带日期的 ID),你买到的是行为稳定。你花两周调出来的 prompt、你积累的 few-shot 样例、你根据模型输出习惯写的那些正则和 JSON 解析器,不会因为平台某天悄悄换了权重就一夜之间开始零星失败。代价是你要自己承担版本的生命周期管理:快照总有一天会下线,届时你得主动迁移;期间模型的能力提升、bug 修复,你也享受不到。
用滚动别名,你买到的是零运维和自动跟进。代价是不确定性:模型换代通常伴随输出风格、冗长程度乃至 JSON 格式偏好的变化,而这些变化不会通知到你的解析层。更麻烦的是回滚——出问题时你想退回「上一个版本」,手里却没有一个能指向的 ID。
我的做法是折中:model ID 做成配置项而不是常量(很多团队栽在这一步),生产钉快照,同时留一条影子流量跑滚动别名,用同一批固定 prompt 定期对比两边输出。真到要升级那天,你手里是有回归数据的,而不是靠感觉拍板。
对称定价会改变你的用法
再看最后一行,DeepSeek-OCR 2 的输入和输出都是 $0.03 /Mt——输入输出同价。
对比表里其它几行:输出价通常是输入价的两倍(Flash 是 0.14 对 0.28,Pro 是 1.6 对 3.2),V3.2 约一点五倍。这个比例默默地在惩罚「让模型多说话」,所以我们习惯性地要求模型输出尽量精简的结构,能省一个字段就省一个。
输入输出同价,等于这层惩罚消失了。在文档解析、结构化抽取这类任务上,它会改变两个决策:
原本为了省钱而压缩的输出结构,可以还原成完整形态。与其让模型只吐一个扁平键值对,不如让它连原文片段、位置信息、逐项判断依据一起吐出来——这些字段对下游的人工复核和自动校验价值很大,以前是被成本卡掉的。
原本为了省 token 而做的「一次总结多页」,可以改成逐单元输出。粒度细意味着出错时能定位到具体哪一段,重试也只需重试坏掉的那一小块,而不是整份文档重来。
这个模型具体接受什么形态的输入、怎么调用,我没核实过,以官方文档为准。我想说的只是:定价结构不只是财务口径,它会一路传导到你怎么设计 prompt 和输出 schema。看到非常规的价格比例,值得停下来想想它允许你做什么以前做不了的事。
五、我不知道的部分,直接说
这篇文章里有两件事我没写,因为没有核实:
免费额度、注册赠送、优惠券——有没有、多少、什么有效期,我都不知道。网上流传的数字往往过期很久,照抄进技术文档等于埋雷,请以控制台和官方活动页为准。
RPM / TPM 之类的速率限制数值——同样没有可信来源,一个数都不写。
不知道限速的时候怎么稳妥起步?我的路子是把服务端限速当成一个待测量的未知数,而不是待查询的常量:
从单并发开始,别一上来就开二十个协程压。先跑通再爬坡,每次翻倍并观察错误率。
在客户端自己设一道并发闸门(一个信号量就够了)。它的价值不在于遵守规则,而在于你随时知道自己实际打出去多少并发——出问题时这是最重要的一个数字。
429 必须走指数退避加随机抖动,有 Retry-After 就尊重它。没有抖动的退避会让实例们整齐划一地同时重试,把一次限流放大成一次雪崩。
超时和重试分开配置,只对幂等失败重试;流式请求的超时单独设,别拿全局值糊弄。
用一小时真实压测样本测出你自己那条「事实限速」,写进文档当作容量规划的输入。这个数比任何官方数字都贴近你的实际情况,因为它包含了你的网络、prompt 长度和并发形态。
关键路径上准备降级方案:备用平台、更小的模型、或者排队缓冲。OpenAI 兼容的好处就在这儿——降级到另一家,理论上只是换 base_url、换 key、换模型 ID 三件事。
六、接入自查清单
上线前对着过一遍:
- base_url 是
https://api.novita.ai/openai,结尾没有/v1、没有多余斜杠,且全项目只有一处定义。 - 代码和网关配置里都没有任何地方在这个地址后面手工拼
/v1或版本段;已经用日志或抓包确认过最终请求 URL。 - 用 curl 直连成功跑通过一次,再回到 SDK 验证,两边行为一致。
- key 从环境变量读取(比如
NOVITA_API_KEY),不在代码、日志、前端出现;开发与生产使用不同的 key;管理入口是https://novita.ai/settings/key-management。 - 模型 ID 按
组织/模型名原样传入model字段,斜杠没有被当成路径或被配置解析器改写。 - 已经决定用版本快照还是滚动别名,理由写在文档里;model ID 是配置项,不是常量。
- 客户端有并发上限、429 退避带抖动、超时与重试分开配置;速率限制的真实边界以你自己的压测数据为准,配额和优惠以控制台为准。
前两条占了我见过的 Novita 接入问题的绝大多数。一个没有 /v1 的 base_url 听起来不像什么大事,但它偏偏踩在所有人的肌肉记忆和封装层的默认行为上,报错还只给你一个 404。先把这条钉死,剩下的都是常规操作。