很多人谈“OpenAI-compatible API”时,容易把它讲成一句很空的话:接口长得像 OpenAI 就行。真到工程里,这种说法是站不住的。因为兼容的对象不是“长得像”,而是客户端和服务端之间那份可预期的契约。

如果这份契约不稳,表面上你也许仍然有 POST /v1/chat/completions,但外部 SDK 接不上,benchmark 跑不通,stream 和 non-stream 行为不一致,前端消费逻辑也会一团乱。

这篇文章想讲清三件事:

  1. OpenAI-compatible 到底兼容的是什么;
  2. 为什么它会成为现代 LLM serving 的事实标准;
  3. mini-infer 是怎样用 mini_infer/serving/openai_schema.pymini_infer/serving/server.pyexamples/openai_client.py 把这套契约落地的。

如果你希望先抓住“这篇到底在解释什么”,可以先记住 mini-infer 当前对外暴露的最小契约:

维度 mini-infer 当前实现
endpoint GET /v1/modelsPOST /v1/chat/completionsGET /healthz
请求体 OpenAI Chat Completions 风格的 model/messages/stream/max_tokens/...
non-stream 响应 choices[0].message.content + finish_reason + usage
stream 响应 SSE,首 chunk 带 role,中间增量在 delta.content,最后 [DONE]
边界策略 schema 里保留常见字段,但对未实现字段显式返回错误

先把“兼容”这件事说准确

所谓 OpenAI-compatible HTTP API,不是说你必须把 OpenAI 的全部产品面完整复刻出来,而是说:

你的服务在路径、请求格式、响应格式和流式语义上,对常见的 OpenAI Chat Completions 客户端保持足够高的兼容度,让外部工具能以很低成本接入。

注意这里有两个关键词。

第一,兼容的是契约,不是实现

外部客户端真正依赖的不是你的内部引擎怎么写,而是下面这些外部行为:

  • 请求发到哪个 endpoint;
  • 请求体长什么样;
  • 响应 JSON 的字段结构是什么;
  • stream 模式下每个 chunk 长什么样;
  • 结束时用什么信号告诉客户端“已经结束了”。

这就是为什么很多推理系统内部完全不同,但对外都长得很像。

第二,兼容是“有边界的兼容”

工程里最忌讳的,不是“没全做”,而是“看起来全支持,实际行为却不一致”。mini-infer 在这点上做得很克制:它明确实现一个 Chat Completions 的受限子集,并把边界说清楚,而不是假装自己已经支持了全部字段和全部语义。

这种做法比“糊一个兼容层”成熟得多。

为什么现代推理系统都在做 OpenAI-compatible

这件事真正解决的是生态接入成本。

1. 客户端不想为每个推理后端单独写一层

如果每个服务都有自己的路径、自己的字段名、自己的 streaming 语义,客户端就必须为每一种后端写适配器。只要接口一换,调用代码就得跟着改。

2. benchmark 和工具链默认就认识这套接口

很多压测脚本、调试工具、前端 SDK、服务网关,本来就是围绕 OpenAI 风格接口设计的。你只要接口兼容,它们就可以低成本复用。

这也是 mini-infer README 里那条主线的意义:服务层不是附属物,它直接决定了系统能不能被外部程序消费、被 benchmark 衡量、被统一接入。

3. 兼容层能把“模型能力”转换成“系统能力”

只有当你的推理引擎被包进标准服务接口,它才真正成为一个可部署、可接入、可观测的系统。否则它只是一个“能在本地跑通的模型程序”。

一个最小可用的 OpenAI-compatible API 到底要兼容什么

如果只抓主线,至少要把四层说清楚:

第一层:endpoint

mini-infer 当前实现的核心入口很清楚:

  • GET /v1/models
  • POST /v1/chat/completions

这是最基本的一组对外服务入口。前者解决“客户端如何发现可用模型”,后者解决“客户端如何发起一次聊天补全请求”。

第二层:请求格式

最常见的一组请求字段包括:

  • model
  • messages
  • stream
  • max_tokens
  • temperature
  • top_p

这些字段不是随便凑出来的,它们分别承载了模型选择、对话上下文、流式输出开关和采样行为。

如果拿 mini-infer 的最小可用请求举例,大致就是这样:

1
2
3
4
5
6
7
8
9
10
11
{
"model": "mini-infer",
"messages": [
{"role": "system", "content": "你是一个简洁的技术助手。"},
{"role": "user", "content": "解释一下 continuous batching。"}
],
"stream": false,
"max_tokens": 128,
"temperature": 0.0,
"top_p": 1.0
}

第三层:响应格式

一个 non-streaming 的响应,通常至少包含:

  • id
  • object
  • created
  • model
  • choices
  • usage

其中 choices 里再放最终的 messagefinish_reason。这套结构的意义是让客户端明确区分“请求元信息”“生成结果”“结束原因”“统计信息”。

一个简化后的 non-streaming 响应大致会长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"id": "chatcmpl-1234abcd",
"object": "chat.completion",
"created": 1712400000,
"model": "mini-infer",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Continuous batching 的核心是把动态到达的多个请求合并到共享的 decode batch 中。"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 24,
"completion_tokens": 18,
"total_tokens": 42
}
}

第四层:stream 语义

stream 不是“把 JSON 拆小一点发”,而是要定义一整套稳定的增量协议:

  • 第一个 chunk 怎么发;
  • 中间内容怎么发;
  • 结束时怎么发;
  • 客户端如何判断整个流已经完成。

如果这里没定义清楚,客户端表面能连上,实际上会经常在边界条件上出错。

mini-infer 是怎么定义这份契约的

mini-infer 把这层协议清晰地拆成了两个文件:

这种拆法非常合理。因为 schema 的任务是定义“对外长什么样”,而 server 的任务是定义“请求怎么被处理”。

schema 层到底在约束什么

openai_schema.py 里的 ChatCompletionRequest 非常值得看:

1
2
3
4
5
6
7
8
9
10
11
class ChatCompletionRequest(BaseModel):
model: str = Field(min_length=1)
messages: List[ChatMessage]
stream: bool = False
max_tokens: int = Field(default=128, ge=1)
temperature: float = Field(default=0.0, ge=0.0)
top_p: float = Field(default=1.0, gt=0.0, le=1.0)
n: int = Field(default=1, ge=1, le=1)
presence_penalty: float = 0.0
frequency_penalty: float = 0.0
stop: list[str] | str | None = None

这里最成熟的点在于:它没有偷懒到只留下“自己当前想支持的字段”,而是保留了一部分常见字段,以便和基础 SDK 生态保持兼容。

但保留字段,不等于承诺完整支持。

把这件事说得更明确一点,mini-infer 当前的策略可以总结成下面这张表:

字段 schema 中存在 当前运行时支持情况
model 必须匹配当前服务暴露的模型 id
messages 支持
stream 支持
max_tokens 支持
temperature 支持
top_p 支持
n 仅允许 1
stop 当前不支持自定义 stop,非默认值会报错
presence_penalty 当前仅接受默认值
frequency_penalty 当前仅接受默认值

为什么 mini-infer 要显式校验“不支持的字段”

这点是我认为这套实现最值得夸的地方之一。

server.py_validate_request() 里,mini-infer 会显式检查:

  • model 是否匹配当前暴露的模型 id;
  • stop 是否为空;
  • presence_penalty 是否仍是默认值;
  • frequency_penalty 是否仍是默认值。

如果用户传了当前服务不支持的字段组合,它不会悄悄忽略,而是返回 400

1
2
3
4
5
6
if request.stop not in (None, "", []):
unsupported.append("stop")
if request.presence_penalty != 0.0:
unsupported.append("presence_penalty")
if request.frequency_penalty != 0.0:
unsupported.append("frequency_penalty")

这背后的设计思想很重要:

协议兼容最怕“假兼容”。宁可清楚地报错,也不要默默吞掉字段,让客户端误以为自己真的受到了支持。

这也是工程成熟度的一部分。很多项目表面上“字段都在”,但行为并不一致,结果就是线上调试成本极高。

OpenAI-compatible 不等于模型直接吃 messages

这是很多人第一次做兼容层时最容易忽略的地方。

客户端发来的是:

1
2
3
4
5
6
7
{
"model": "mini-infer",
"messages": [
{"role": "system", "content": "..."},
{"role": "user", "content": "..."}
]
}

但底层模型真正吃进去的不是这段 JSON,而是模型自己的 prompt 格式。

mini-inferAsyncEngine.format_prompt() 里优先调用 tokenizer 的 apply_chat_template(),把协议层的 messages 转成模型输入字符串。对 Qwen 这类聊天模型来说,这一步至关重要,因为它决定了:

  • system / user / assistant 的角色边界;
  • generation prompt 如何补齐;
  • 模型能不能按预期进入“继续生成 assistant 回复”的模式。

所以你可以把 OpenAI-compatible API 理解成“外部输入的标准化”,但服务端仍然需要负责把这份标准输入翻译成模型能消费的内部表示。

这也是为什么“协议兼容”和“模型实现”一定要分层。客户端关心 messages,模型关心 prompt;中间这层翻译不应该被省略,也不应该被写死到前端。

non-streaming 响应为什么要这么组织

server.py 里,non-streaming 路径走的是 _non_stream()。它会调用:

  • engine.generate_with_reason(...) 获取完整文本和结束原因;
  • tokenizer 对 prompt 和生成结果分别计数;
  • 最终构造 ChatCompletionResponse

这里面最值得注意的字段有三个。

choices

很多人会把它看成一个历史包袱,但其实它很有用:它把“一个请求可能存在多个候选输出”这个能力抽象留了出来。即使当前 mini-infer 只支持 n=1,这层结构仍然应该保留。

finish_reason

内部引擎的结束状态和对外协议里的结束状态,不一定一一对应。比如内部可能用 eos,但外部协议通常更希望看到 stopmini-infer_normalize_finish_reason() 做了一层归一化,这就是典型的“内部语义到协议语义”的转换。

usage

usage 不是装饰字段。对很多客户端、计费逻辑、观测逻辑来说,它是非常实用的统计信息。哪怕系统当前不做计费,这个字段也会被很多上层工具默认依赖。

如果把 stream 和 non-stream 放在一起看,它们其实共享同一份“语义骨架”:

维度 non-stream stream
模型标识 model 每个 chunk 都带 model
完成原因 finish_reason 最后一个 stop chunk 里给出
内容承载位置 choices[0].message.content choices[0].delta.content
结束信号 整个 JSON 返回完成 最后 data: [DONE]

stream 模式真正兼容的是什么

stream 路径是 OpenAI-compatible API 最容易“看起来差不多,实际并不一样”的地方。

mini-infer 里,stream 请求会返回:

1
2
3
4
StreamingResponse(
_stream_generator(engine, prompt, request),
media_type="text/event-stream",
)

然后 _stream_generator() 会按下面这个顺序产出 SSE 数据:

  1. 先发一个只带 role="assistant" 的首 chunk;
  2. 中间不断发 delta.content 的增量 chunk;
  3. 结束时发带 finish_reason 的 stop chunk;
  4. 最后发 data: [DONE]

这套顺序不是实现细节,而是协议行为。

如果把原始 SSE 数据写出来,mini-infer 的流式路径大致可以理解成这样:

1
2
3
4
5
6
7
8
9
10
data: {"choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":"Continuous batching "},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":"的核心是共享 decode batch。"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"stop"}]}

data: [DONE]

为什么要先发 role-only chunk

因为客户端需要先知道“这次增量输出的角色是谁”。这和后续只发纯内容 delta 是两回事。首 chunk 先把角色交代清楚,后面的 chunk 才能专注于追加文本。

为什么最后必须有 [DONE]

因为客户端不能只靠“连接断开了”来推断“响应正常结束了”。应用层需要一个明确的完成信号,告诉客户端这不是中途断网,而是协议意义上的正常结束。

这也是为什么 [DONE] 在 OpenAI 风格的流式接口里几乎成了事实标准。

/v1/models 为什么不是可有可无

很多 demo 只做 POST /chat/completions,觉得模型列表接口没什么意义。但实际工程里,GET /v1/models 很有价值:

  • 客户端能先发现当前可用模型;
  • 调试时可以快速检查服务是否真的按预期暴露了模型 id;
  • 统一网关或 SDK 初始化时,往往会先探测模型列表。

mini-infer 在这里保持了克制:当前只返回一个 ModelCard(id=_model_id, created=0),但对外契约已经具备了。这种“接口先稳定,再逐步扩展实现”的思路,是非常典型的工程做法。

这里顺手提一句,/healthz 虽然不是 OpenAI 标准接口的一部分,但它对真实服务非常重要。mini-infer 把它和兼容层放在同一个服务模块里,也是一个很合理的选择:对外协议不只包括“怎么生成”,也包括“服务是否健康”。

为什么这套设计对 benchmark 和接入都友好

mini-infer 不是只把服务跑通了,它还把这套协议真正用于接入和测量。

一个直接证据:examples/openai_client.py

这个例子只依赖 Python 标准库,通过:

  • GET /v1/models
  • POST /v1/chat/completions

就能同时演示 non-streaming、streaming 和多轮对话。

这说明它对外暴露的接口,不只是“为了看起来像 OpenAI”,而是真的已经足够让外部客户端直接消费。

另一个证据:服务 benchmark

在仓库的 docs/benchmarks.md 里,HTTP serving 的吞吐测试就是围绕这条标准服务路径做的。并发从 1 提升到 8 时,吞吐从 55.7 tok/s 增加到 219.1 tok/s。这说明协议层不是摆设,它已经成为系统性能评估的一部分。

做 OpenAI-compatible 最容易踩的坑

如果你以后自己实现兼容层,我建议优先盯这几类问题。

1. stream 和 non-stream 的语义不一致

最常见的问题是:两条路径最后的文本看似一样,但 finish_reason、首 chunk、结束标志、usage 统计方式完全不一致。客户端最怕这种“半兼容”。

2. schema 里有字段,运行时却悄悄无视

这比明确报错更危险。因为你会制造一个错觉:接口看起来支持,实际上服务端根本没处理。

3. 忽略 messages -> prompt 这层翻译

很多兼容层只会把请求接进来,却没有认真处理聊天模板。结果是 API 看起来对了,模型输出却完全不对。

4. 把兼容层和引擎层写死在一起

一旦协议层逻辑和底层推理代码缠在一起,后面想扩展字段、替换引擎、增加更多 endpoint,成本会迅速变高。

建议和另外两篇一起看

这篇主要讲“契约”。如果你想把整条链路看完整,建议顺着读:

写在最后

OpenAI-compatible API 真正有价值的地方,不是“模仿 OpenAI”,而是把推理引擎变成一个更容易接入、更容易测量、更容易迁移的标准化服务。

mini-infer 这套实现的优点,恰恰在于它没有把兼容说成一个模糊口号,而是把契约一层层落成了代码:

  • schema 定义请求/响应长什么样;
  • server 负责路由、校验和协议行为;
  • AsyncEngine 负责把协议层请求接到真实引擎上;
  • client 和 benchmark 直接消费这套接口。

把这条链路讲顺之后,你对 OpenAI-compatible 的理解就不会停留在“接口像不像”,而会回到更本质的问题:这份接口契约,是否足够稳定、清晰,并且真的能支撑外部生态接入。