每次要把模型挂到端点后面,我都会做同一个偷懒的决定:随手挑上次用过的,或者最近刚读到过的那个,然后告诉自己“回头再正经跑一次基准测试”。可“回头”永远不会来——总有真正赶截止日期的活儿排在前面,而比较模型延迟这种事,哪怕不算拖延,感觉上也像是在拖延。所以我从没做过,一次都没有。
于是我干脆写了个工具,逼自己必须做这件事。一段提示词,同时打给六个模型,六列并排流式输出,每一列下面标着首 token 耗时和单次成本。总共大约 390 行 Python。代码在这里,MIT 协议,随便拿去用。
然后我跑了一下,结果发生了三件完全没预料到的事。

接入只需两行代码,而这恰恰是最没意思的部分
DigitalOcean 的推理端点兼容 OpenAI 接口,所以全部代码就这么多:
client = OpenAI(
base_url="https://inference.do-ai.run/v1/",
api_key=os.environ["DIGITAL_OCEAN_MODEL_ACCESS_KEY"],
)
下面列出的每个模型,走的都是同一个客户端。Llama、DeepSeek、Mistral、Qwen,还有 OpenAI 开放权重的 gpt-oss 系列。变的只是模型字符串。
这就是卖点,也确实名副其实。不过这部分我想快点带过——OpenAI 兼容端点用起来就像个 OpenAI 兼容端点,你早就心里有数。我事先没想到的,是接下来的一切。
在你复制那段代码之前,先补一条注脚。凭据是 Gradient AI Platform 下创建的模型访问密钥(model access key),不是 Settings → API 页面里的那个 API token。两回事,两个页面。(不过后来我发现,端点本身对这层区别的在意程度,远没有文档那么高。)
六条流,不用事件循环
我想让几列同时往上填。是真刀真枪的竞速,而不是六条排成串的进度条假装在跑。
规整的做法是用一个端点在服务端分发,再把所有数据复用到一条连接里传回。我没走这条规整路。浏览器改为给每个模型各开一个 EventSource:
GET /stream?model=<id>&prompt=<text>
六个模型,六条连接,六个各自独立的生命周期。谁也不合并谁。Flask 保持同步,不用 async,没有编排层,整条流式路径加起来也就四十来行代码。
我这么写是因为它更简单,这一点我至今坚持。但事后让我庆幸当初这么选的理由,跟当初选它的理由并不是一回事——这个我后面会讲。
我猜错了 bug
接下来就是我白白耗掉一小时的地方。
我早就知道 gunicorn 默认的 sync worker 会出问题。每个 worker 进程一次只处理一条连接,还会一直占着不放,直到响应完成。对付只持续 40 毫秒的请求,这没问题。可流式响应一开就是好几秒,六个并发流就得有六个 worker,否则只能排队。
我原本预测页面会卡住。结果它没卡。下面是六个并发流同时压在一个 sync worker 上的真实表现:
| 模型 | 首 token |
|---|---|
| mistral-3-14B | 1250 ms |
| openai-gpt-oss-120b | 5326 ms |
| openai-gpt-oss-20b | 7278 ms |
| deepseek-3.2 | 8347 ms |
| llama-4-maverick | 9147 ms |
注意看这些时间的间隔。每条流的首 token,差不多都是在上一条流结束的那一刻才出现。这不是模型慢,这是在排队。六个请求,一次只跑一个,花了 10.7 秒才全部处理完。
换成线程 worker:
web: gunicorn --worker-class gthread --threads 16 --timeout 120 'app:create_app()'
现在,六个首 token 中有四个都落进了 1.4 秒的窗口,而不是像行军一样铺满整整十秒,整个过程也缩短到了 6.4 秒。
但回头再看看第一张表,因为真正有意思的不是这次修复。那几次请求全部成功了:响应正确、没有超时、没有报错,日志里也干干净净。要是我把那个有问题的版本直接发布了,我可不会给自己提 bug,只会看着表格里的一列列数字陆续填满,认定是模型太慢,然后跑去写一个缓存层,去解决一个其实一直躺在我 Procfile 里的问题。卡死反倒更仁慈。卡死至少会逼你去看一眼自己的服务器。
这份目录会骗你
模型选择器是基于 GET /v1/models 构建的,因为把模型列表写死在代码里,总有一天会让你发布出一个早已下线的模型。
这个接口返回 72 个模型,而我的账号能调用的却只有六个。
凡是 Anthropic 的模型,再加上 GPT-4o 和 o3,返回的都是:
403 - {'error': {'message': 'this model is not available for your
subscription tier', 'type': 'forbidden_error'}}
/v1/models 的响应里没有任何字段能告诉你哪个是哪个。没有可用性标志,没有层级字段,连一点提示都没有。想知道答案,只有实际调用一次,然后看返回的 403。
就这样,我上线了一个坏掉的默认配置。我的预选列表里赫然写着 anthropic-claude-haiku-4.5——它出现在公开的模型目录里,定价页上也有它的名字,旁边还标着实打实的按 token 计费单价。可从查阅那两份文档到写下列表,整个过程没有任何迹象提醒我:该先检查一下自己的账号能不能调用这个模型。结果第一次真实运行,那一列就在我眼前变红了。
还记得那六条相互独立的连接吗?挂掉的模型抛出 403,错误显示在它自己那一列,另外五条照样在流式输出,仿佛什么都没发生。我当初做这套隔离设计,防的是假想中的故障。结果第一次接触 API 才过了大约六十秒,第一场真实的故障就来了。
如果你正在写任何需要从这个端点拉取模型来填充菜单的功能——DigitalOcean 的文档恰恰就是把你往这儿引的——那就默认返回的模型大多调不通。
关于凭证的一点小事
文档说得很明确:模型访问密钥和 API 令牌是两种不同的凭证。确实如此。但 dop_v1_... 这样的 API 令牌照样能在 inference.do-ai.run 上通过认证。我分别在那边和 api.digitalocean.com/v2/account 上都验证过,两个地方都能用。
即便如此,还是用权限更窄的那个。模型访问密钥泄露,顶多损失一些推理费用;API 令牌泄露,赔上的就是整个账号。
数据给出的答案
三种提示词类型(简短的事实问答、长篇解释、代码生成),六个模型,各跑三遍。共 54 次调用,max_tokens=512,区域设为 nyc,晚上十点左右,从欧洲的一台笔记本上发起。

各模型九次运行的中位数:
| 模型 | TTFT | 总耗时 | 成本 | 无文本输出次数 |
|---|---|---|---|---|
| mistral-3-14B | 533 ms | 3.3 s | $0.000108 | 0/9 |
| llama-4-maverick | 676 ms | 17.9 s | $0.000362 | 0/9 |
| deepseek-3.2 | 869 ms | 5.4 s | $0.000416 | 0/9 |
| openai-gpt-oss-20b | 1792 ms | 4.6 s | $0.000235 | 2/9 |
| openai-gpt-oss-120b | 4797 ms | 16.7 s | $0.000367 | 0/9 |
| qwen3.5-397b-a17b | 9332 ms | 32.0 s | $0.000995 | 7/9 |
Mistral 14B 在我测量的每一项指标上都拿了第一:首 token 最快、总耗时最短、单次成本最低,而且每一次都正常作答。这里根本不存在可供取舍的权衡曲线。就这个工作负载而言,贵的模型没给我带来任何好处——这既不是我预期的结果,也不是我跑测试前一天能猜到的结果。
首 token 延迟从 533 毫秒到 9.3 秒不等,差距足足 17 倍。只要模型躲在用户需要等待的功能背后,这个差距就决定了功能是否可用,而这一点从模型卡上根本猜不出来。
再来看最后一列。
没有一次失败,九次运行却返回了空白。
全部 54 次调用都成功了:没有异常,没有非 200 响应,也没有超时。随便接进哪套监控系统,记录都是干干净净的。
其中有九次调用连一点可读文本都没返回,费用却是全价。
qwen3.5-397b-a17b 九次里有七次是这样。它同时也是这场比赛里最贵的模型,价格约为 Mistral 的 9 倍,也是最慢的,一趟要 32 秒。32 秒的等待、最贵的账单、空空如也的输出框。
这是个推理模型。实际情况是:512 个 token 的预算里,它花了 487 个用来思考,还没来得及写下正式答案的一个字,额度就耗尽了,而所有思考内容都被流式传输到了一个叫 delta.reasoning_content 的字段里——这个字段并不在 OpenAI 的 schema 之中,因此全世界所有兼容 OpenAI 的客户端都看不见它,我的也不例外。于是,请求成功了,token 照常计费,输出框却依然空着。
completion_tokens: 512
reasoning_tokens: 487
content: 0 characters
你可以照付全款,换回一片沉默,然后眼看着监控面板宣布一切成功。
我给应用打了个补丁:再遇到内容为空、reasoning_tokens 却不为零的列,让它自己把情况说清楚,而不是干杵在那儿,一副坏掉了的样子。把 max_tokens 调高,Qwen 确实会回答。行吧。但比我的这个具体 bug 更值得警惕的,是它的一般版本:如果你的评测只盯着延迟和状态码,那它从结构上就注定看不见这类失败。你必须亲自去看返回了什么。
而这一点,说来有点尴尬,恰恰就是我做那个把输出与数字并排摆在一起的工具的全部理由。我起初可没打算验证自己的立论,只是这事一而再、再而三地上演。
账单
$0.0185。54 次调用的全部费用。
读定价页花掉的时间,比跑完整个实验还长。这一下让我对整件事有了新的看法。人们在选模型之前不做这类测试,原因不在成本,其实也不在时间,而在于手头没有现成能跑的东西。现在,有了一个。
我还会再用它吗
如果是为某个具体任务挑模型——会。现在我对 Mistral 14B 有了上周还不存在的看法,而且这些看法来自实测数据,不是我顺手刷过的某个帖子。
摩擦是真实存在的,但都不算大。目录里挂着根本调不通的模型。凭据的区分,实际执行得没有文档描述的那么严格。还有一个 worker 配置陷阱,无论什么平台上的流式应用都会中招。三者中只有第一个真正归咎于 DigitalOcean,也是我最希望被修掉的一个。给 /v1/models 加一个 available 字段,一个下午的活儿,就能让所有人都免吃那个 403。
我真正想推荐的,反而是开头被我一句带过的那个无聊环节。通常对比四家服务商,意味着四个 SDK、四套互不相同的流式约定、四个密钥散落在四处、四块仪表盘,外加月底四张账单——等你把这套管线全跑通,花在上面的功夫早就超过了你最初要回答的那个问题本身。而在这里,对比不过是改一份字符串列表。
丑话说在前面:n=3,一个区域,一个晚上,一种账户档位,一组提示词,外加一台位于欧洲的笔记本去请求纽约的数据中心。这不是基准测试,这只是一个开发者的周三之夜。重点从来就不是交给你一组权威数字,而是把门槛压得足够低,低到你愿意亲自去跑出属于自己的数据——用你自己的提示词,你自己的账户。
代码:github.com/oceanforge/inference- shootout。NOTES.md 里有原始构建日志,连那些当场出错的片段都记录在内;docs/measurements.json 则存着全部 54 次运行的原始数据,不服气的话尽管去翻。
本文是 oceanforge 项目的一部分——这是一系列可自行部署到 DigitalOcean 云端的小型应用。与 DigitalOcean 并无隶属关系,纯粹是喜欢在上面折腾些小玩意儿罢了。