Novita 调不通/报错:404 多半出在 base_url
接过几家 OpenAI 兼容端点之后,我大概能凭报错的第一眼判断问题出在哪一层。Novita 这家有个很明显的特征:新手第一次调不通,八成收到的是 404,而不是 401 或者参数错误。
麻烦的是 404 这个状态码在人的直觉里指向「东西不存在」,于是大多数人拿到 404 的第一反应是去怀疑模型名——是不是模型下架了?是不是拼写错了?是不是这个账号没权限用这个模型?然后就开始翻模型列表、改模型 ID、试各种大小写组合,一个小时过去了还在原地。
方向从一开始就错了。在 OpenAI 兼容协议下,模型不存在通常会由服务端返回一个带 error 结构的业务错误,而不是干脆利落的 404 路由未命中。真正会给你干净 404 的,是请求根本没打到那个接口上。也就是说,先怀疑地址,再怀疑模型。
下面按这个顺序展开:地址、鉴权、模型 ID。这三层是我在事实层面能确认的,其余部分我会明确标出哪些是 HTTP 的通用约定、哪些是必须去查官方文档的。
一、地址:base_url 没有 /v1 后缀
这是 Novita 最值得单独拎出来讲的一点。
它的 base_url 是:
https://api.novita.ai/openai
注意结尾,到 /openai 就结束了,后面没有 /v1。
这一点之所以坑,是因为它和你的肌肉记忆是冲突的。OpenAI 官方的地址结尾是 /v1,市面上大部分兼容端点也都把 /v1 带在 base_url 里,久而久之「base_url 必须以 /v1 结尾」就变成了很多人下意识的习惯。于是有两种典型的踩法:
第一种是你自己补的。 复制文档里的地址,看一眼觉得「怎么少了个 v1」,顺手补上,变成 https://api.novita.ai/openai/v1。请求打出去,路径上多了一段不存在的层级,404。
第二种更隐蔽,是 SDK 帮你补的。 有些客户端封装(尤其是第三方的、或者团队内部自己包了一层的)会在内部做路径拼接:你给 base_url,它负责拼 /v1/chat/completions。这类封装在设计的时候就假定了 base_url 不含版本段。你按文档老老实实填了 https://api.novita.ai/openai,它拼出来的却是 https://api.novita.ai/openai/v1/chat/completions——同样 404,而且这次是你没动手它自己加的,你盯着配置文件看半天也看不出问题。
反过来也有:某些封装假定 base_url 已经包含版本段,只负责拼 /chat/completions。同一份配置在两个封装之间搬,结果就是一边通一边不通。
用 curl 把地址钉死
碰到这类问题,最快的路子是先把 SDK 撇开,用 curl 直接打你认为正确的完整接口地址。curl 的好处是没有任何隐式拼接,你写什么就发什么,能把「地址对不对」和「SDK 有没有二次拼接」这两个问题彻底分开。
curl -i https://api.novita.ai/openai/chat/completions \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek/deepseek-r1",
"messages": [{"role": "user", "content": "hi"}]
}'
这里用 -i 而不是静默模式,是为了把状态码和响应头一起打出来。看结果分三种情况:
- 返回 200 或者一个带
error结构的业务错误:说明地址通了,路由命中了服务端。剩下的问题在鉴权或参数层,跳到第二节、第三节。 - 返回 404:地址没命中。把 URL 逐段减掉再试——先确认
https://api.novita.ai/openai这个前缀本身是文档写的那个,再确认你有没有多加或少加路径段。这一步不要靠猜,回去比对官方文档的原文地址,一个字符一个字符地看。 - 连不上、超时、TLS 报错:那已经不是接口问题了,是网络出口或证书链的问题,先在同一台机器上确认域名能不能解析、出网策略有没有拦。
通了之后要做一步倒推:把 curl 里那个完整地址减去 SDK 会自动拼接的部分,剩下的才是 base_url 该填的值。比如你的客户端会自动拼 /chat/completions,那 base_url 就填到 /openai 为止;如果它自动拼的是 /v1/chat/completions,那这个封装和 Novita 的地址结构就不兼容,你得改封装或者换个客户端,而不是靠改 base_url 去凑。
让 SDK 把实际请求 URL 打出来
倒推完还有最后一道保险:确认 SDK 真正发出去的 URL 和你以为的一致。这一步很多人省掉,然后在「配置明明是对的」里耗掉一整个下午。
办法有几个,从轻到重:
- 打开客户端的调试日志。多数 HTTP 客户端库都有开关能把请求行打出来,具体环境变量或参数名以你用的那个库的文档为准。
- 在本地起一个回显服务,把 base_url 临时指过去,看它到底请求了什么路径。这个办法最直接,因为它绕过了所有文档和猜测——服务端收到什么就是什么。
- 抓包或者挂代理。重一点,但对付那种层层封装的老项目最有效。
我的习惯是在项目里留一个开关,把「实际请求的完整 URL」在启动自检时打一次。上线之后关掉,出问题时打开,省下的排查时间远超这点代码成本。关于 base_url 这个字段本身的通用踩法,可以再看一下 base_url 到底该填什么,那篇把各家的结尾差异摊开讲了。
二、鉴权:Bearer 头和那些看不见的空白字符
地址通了之后,第二层是鉴权。
Novita 的鉴权头是标准形式:
Authorization: Bearer ${API_KEY}
key 在这个页面管理:https://novita.ai/settings/key-management。
鉴权这一层的报错通常比较诚实,不太会误导你。按 HTTP 的通用约定(这是协议层的语义,不是我从这家平台的错误码表里抄的——那份清单我没有核实过,本文不写):
- 401 Unauthorized:凭据缺失或者无效。翻译成人话就是「你是谁我不认识」。常见于 key 拼错、key 已经被删掉、根本没把这个头带上、或者头的名字写错了(写成
authorization一般没事,HTTP 头名不区分大小写,但写成Api-Key之类的自定义名就不行了)。 - 403 Forbidden:身份认出来了,但这个身份不允许做这件事。翻译成人话是「我知道你是谁,但你不能干这个」。
这两个的区分很有用:401 说明问题在 key 本身,403 说明问题在权限或者账号状态上,排查方向完全不同。拿到 401 就去检查 key 的传递链路,拿到 403 就去控制台看账号侧的状态——具体每家平台在什么情况下返回哪个码会有差异,以官方文档为准。
复制 key 时带进来的脏东西
有一类 401 特别气人,因为你把 key 打印出来看,它长得完全正确。
问题出在肉眼看不见的字符上:
- 尾部换行。用
echo "sk-xxx" > .env这类命令写文件时带进去的\n,或者从网页复制时多选了一个换行符。 - 首尾空格。从聊天工具、文档里复制时很容易带上。
- 不间断空格(NBSP)。从渲染过的网页上复制最容易中招,它长得和普通空格一模一样,但字节完全不同。
- BOM 头。Windows 上某些编辑器保存 UTF-8 时会在文件开头加三个字节,如果这个文件是
.env,第一行的变量名或值就被污染了。 - 引号一起复制进去了。
API_KEY="sk-xxx"在 shell 里没问题,但如果是某些配置格式,引号会被当成值的一部分。
诊断方法很朴素——不要 print,要看长度和字节:
import os
k = os.environ.get("API_KEY", "")
print(repr(k)) # 换行、空格会以转义形式显示出来
print(len(k)) # 和控制台上 key 的实际长度比对
print(k == k.strip()) # False 就说明首尾有空白
在代码里对 key 无脑做一次 .strip() 是个便宜的保险。但只 strip 不告警是坏习惯:静默修掉之后,下次别人从同一个渠道复制 key 时还会踩,而且再也不会有人发现。我的做法是 strip 之后如果发现值变了,就在启动日志里打一条 warning,把问题暴露给下一个人。key 的存放、轮换、别提交进仓库这些事,API key 管理的基本纪律 那篇讲得更全;如果你现在手上就是一个 401,401 报错的排查路径 可以直接照着走。
三、模型 ID:写错报错还算好的,写错不报错才要命
第三层是模型 ID。Novita 的模型 ID 是两段式,组织/模型名 的风格,文档里出现过的示例是:
deepseek/deepseek-r1
最常见的错法是把组织前缀吞掉,只写 deepseek-r1。这种一般会失败,你能立刻发现——虽然烦,但至少是显性的。
真正难缠的是另一种。
版本快照:不报错,但你拿到的不是同一个模型
Novita 的定价页上,同一个模型口径会同时出现带日期后缀和不带日期后缀的两个条目。比如 Deepseek V4 Flash 0731 和 Deepseek V4 Flash 是并列列出的两行,而且这两行的挂牌价一模一样:输入都是 $0.14 /Mt、输出都是 $0.28 /Mt(这是官方定价页上的价格,其余型号与价格以官方定价页为准)。
价格一样,恰恰是最容易让人放松警惕的地方——「反正都一样贵,随便填一个呗」。
但价格相同不代表模型相同。带日期后缀的那个通常是一个固定的版本快照,而不带后缀的那个更像是一个会滚动的别名。这两者在工程上的含义差得很远:
- 你钉了快照,模型行为就锁死了,官方发新版你不受影响,但也享受不到改进,而且快照总有下线的一天。
- 你用了别名,官方一更新你就跟着变。好处是自动吃到改进,坏处是某天早上你的 prompt 突然不听话了,而你的代码一行都没改。
这就是比报错更难发现的问题:写错版本不会报错。请求 200,返回也是一段像模像样的文本,你的监控全绿,只有输出质量、格式稳定性、token 消耗在悄悄漂移。等业务方反馈「最近答得怪怪的」,已经是几天之后,你去翻代码根本翻不出改动记录,因为确实没人改过。
具体哪些模型 ID 可用、后缀的确切写法、快照的生命周期策略,以官方文档和控制台的模型列表为准——这些我没有逐项核实,不在这里替你写死。我能给的是防护姿势。
怎么防
第一,模型 ID 收进配置,不许散落在代码里。
我见过太多项目把模型名硬编码在四五个不同的调用点上,改的时候漏掉一个,于是线上同时跑着两个模型,A/B 效果对比彻底失去意义。正确做法是集中成一个配置项,从环境变量或配置文件读,代码里只引用这一个常量。
# config.py —— 单一事实来源
MODEL_ID = os.environ["NOVITA_MODEL_ID"] # 不给默认值,逼配置显式声明
这里刻意不写默认值。给默认值意味着某台机器忘了配也能跑起来,然后跑的是你三个月前随手写的那个值——这正是我们想避免的场景。宁可启动失败,也不要静默跑错。跨平台的模型 ID 命名差异,各家模型 ID 的命名风格 里有横向对照。
第二,启动日志打印生效值。
服务起来的第一条日志,就把这次实际生效的关键配置打出来:
[startup] base_url=https://api.novita.ai/openai
[startup] model_id=deepseek/deepseek-r1
[startup] api_key=sk-****(len=51)
key 只打前缀和长度,绝不打全。这三行日志的价值在于:出事故的时候你不用去猜「当时线上跑的到底是哪个配置」,翻一下日志就有答案。回滚、对比、复盘,全靠它。
第三,把模型 ID 带进可观测数据。把它作为一个标签打进你的调用日志或指标里。这样质量出现漂移时,你可以直接按模型 ID 分组看,一眼看出是不是换了模型引起的。没有这个维度,你就只能靠回忆。
第四,升级模型 ID 走正常的变更流程。改这个值等同于改依赖版本,该走的评审、该跑的回归评测集一个都不能少。它看起来只是改了一个字符串,实际上换掉的是整个系统里最不可控的那个组件。
四、通用 HTTP 语义:这一节是协议约定,不是这家的错误码表
必须先说清楚:Novita 的完整错误码清单、每个码的确切含义,我没有核实,本文不列。网上流传的各家错误码对照表经常互相抄,抄错一个你就白排查半天。要看错误码,去官方文档看。
但 HTTP 协议本身的语义是通用的,这部分可以放心用来做第一层分诊。以下是 HTTP 通用约定:
| 状态码区间 | 含义 | 该怎么处理 |
|---|---|---|
| 2xx | 成功 | 正常走业务逻辑 |
| 4xx | 请求方的问题 | 改代码/改配置,重试没有意义 |
| 429 | 请求过多 | 退避后重试,这是 4xx 里的例外 |
| 5xx | 服务端的问题 | 可以重试,配合退避 |
| 超时 / 连接失败 | 链路问题 | 可以重试,但要小心重复扣费 |
几条实操上的取舍:
4xx 别重试。 这是我见过最常见的错误配置。有人图省事,在 HTTP 客户端上开个全局重试,所有非 200 都重试三次。结果 400、401、404 这些改代码才能解决的错误,被无脑重试三遍,日志里的错误量翻三倍,问题还是那个问题。4xx 意味着「你发的东西不对」,同样的东西再发三次,答案不会变。
429 单独处理,退避要指数退。 固定间隔重试在高并发下会造成「重试风暴」——一批请求同时被限流,然后又同时重试,把服务端再打一遍。指数退避 + 随机抖动是标准解法:第一次等 1 秒,第二次 2 秒,第三次 4 秒,每次再叠加一个随机偏移让请求散开。同时要设最大重试次数和总超时上限,否则一个请求可能挂在那里几分钟不返回。
5xx 和超时可以重试,但要区别对待。 5xx 一般意味着请求到了服务端但处理失败了;超时则更含糊——你不知道服务端到底有没有收到、有没有开始算。对非流式的对话补全来说,重试一次超时请求通常可以接受,但如果你的调用有副作用(比如触发了下游写操作),就得考虑幂等性了。
把重试的次数和原因记进日志。 不然你只会看到「服务很慢」,看不到「其实是每个请求都在偷偷重试两次」。
五、不知道限速数值时,怎么稳妥起步
我不知道 Novita 的 RPM/TPM 具体是多少,也不知道有没有免费额度、赠送多少、有效期多久——这些我没有核实,所以一个数字都不写。以官方文档和控制台的账单页为准。
但「不知道具体数值」不等于「没法安全上线」。下面这套起步方式我在任何一家没查到限速文档的平台上都这么干,它不依赖任何具体数值:
并发从个位数起步。 别一上来就开 50 路并发压。先用 2 到 4 路跑通全链路,观察一天。这个阶段你的目标不是把吞吐拉满,是把错误类型摸清楚。
先观测,再加压。 加压之前,先把这几个指标接出来:请求量、错误率(按状态码分开统计)、P95 延迟、每次调用的 token 消耗。有了这四个数,你才知道加压之后哪里先出问题。没有这些数就加压,出了问题只能全量回滚。
加压要阶梯式。 每次把并发翻一倍,跑够一段时间看错误率有没有变化。一旦开始出现限流类的错误,就退回上一档,那就是你的实际可用上限。这个数比任何文档上的数字都准,因为它是你自己的账号、你自己的模型、你自己的请求大小跑出来的。
退避重试在第一天就装好,不要等出问题再加。 这东西的成本很低,但它是你在不知道限速阈值的情况下唯一的安全垫。
用小批量跑出自己的单位成本。 别拿定价表上的单价直接乘请求数,那个算出来的数永远偏低——真实场景里 system prompt、few-shot 示例、多轮历史都会把输入 token 顶上去。正确做法是跑一两百个真实请求,把实际消耗的输入/输出 token 记下来,除以请求数,得到「每次业务调用的平均 token 消耗」。拿这个数去乘官方单价,才是能拿去做预算的数字。
给自己设一个消费上限告警。 不管平台有没有提供这个功能,你自己在监控侧按累计 token 数算一个估算值,超过阈值就告警。这一步能挡住绝大部分「循环里忘了 break,一夜之间烧掉预算」的事故。
六、照着这个顺序查
从上到下,查完一条再下一条,不要跳:
- 用 curl 打完整接口地址,把 SDK 完全撇开。这一步能同时排除地址错误和 SDK 二次拼接,是信息量最大的一步。
- 拿到 404 先怀疑地址,别怀疑模型名。确认 base_url 是
https://api.novita.ai/openai,结尾没有/v1。 - 确认 SDK 的拼接行为:让它把实际请求 URL 打出来,和 curl 里通了的那个逐字符比对。
- 401 就查 key 的传递链路:用
repr()和len()看有没有换行、空格、NBSP、BOM;403 则去看账号侧的权限和状态。 - 模型 ID 用两段式,
组织/模型名,别吞掉组织前缀;带不带日期后缀的版本快照要明确选定,具体可用 ID 以官方文档为准。 - 把 base_url、模型 ID 收进配置,启动时打印生效值(key 只打前缀和长度)。这一条防的是不报错的那类事故。
- 重试策略按 HTTP 通用语义分开写:4xx 不重试,429 指数退避加抖动,5xx 和超时限次重试。
- 限速和额度去官方文档查,查不到就低并发起步、先观测再加压,并用小批量真实请求跑出自己的单位成本。
最后说一句关于心态的。这类接入问题给人的挫败感,往往不是因为难,而是因为方向错了却浑然不觉——盯着模型名改了两小时,问题其实在地址结尾少了或多了三个字符。所以排查的第一原则不是「想哪里最可能错」,而是「用最便宜的手段把一整层排除掉」。curl 就是那个最便宜的手段。