兼容 OpenAI API:让 mini vLLM 接入真实应用

前面几篇文章里,我们已经把 mini vLLM 的推理内核做得越来越完整。

它现在已经有了请求状态抽象、Scheduler、prefill 和 decode 分离、流式输出、Block Manager、PagedAttention、Prefix caching、Chunked prefill、Sampling 系统、benchmark、speculative decoding、tensor parallel 和量化推理。

这些能力构成了一个推理引擎的核心。

但如果我们想让它真正被应用使用,还差最后一层非常重要的东西:服务协议。

一个推理引擎再强,如果只能通过内部 Python 函数调用,就很难接入真实业务。真实应用更希望通过 HTTP API 访问模型。更进一步,如果这个 API 能兼容现有生态中常见的 LLM 调用格式,那么很多上层应用几乎不用改代码,就可以切换到我们的推理服务。

这就是 OpenAI API 兼容层的价值。

这一篇我们要做的不是完整复刻所有 OpenAI API 能力,而是实现一个足够实用、结构清晰、可以不断扩展的兼容层。

第一版重点支持两个接口:

/v1/completions
/v1/chat/completions

其中 /v1/completions 面向传统 prompt completion 形式,输入是一段 prompt,输出是一段补全文本。

/v1/chat/completions 面向对话形式,输入是 messages,输出是 assistant message。

这两个接口最终都会被转换成 mini vLLM 内部的 Request、Sequence、SamplingParams 和流式输出队列。

也就是说,API 层不应该重新实现推理逻辑。

它只是把外部协议翻译成内部引擎能理解的结构,再把内部 RequestOutput 翻译回外部协议。

API 层的职责不是推理,而是翻译

到目前为止,我们的 Engine 已经有了比较清晰的内部接口。

它接收 prompt tokens 或 prompt text,创建 Sequence,然后由 Scheduler 和 ModelRunner 推进生成。每一轮 step 会产生 RequestOutput。流式请求可以不断消费输出队列,非流式请求可以等待 finished 后一次性返回完整结果。

API 层要做的是把 HTTP 请求变成这些内部对象。

从外部看,用户可能发送的是:

{
  "model": "mini-llama",
  "messages": [
    {
      "role": "system",
      "content": "You are a helpful assistant."
    },
    {
      "role": "user",
      "content": "Explain KV cache in simple terms."
    }
  ],
  "temperature": 0.8,
  "top_p": 0.95,
  "max_tokens": 256,
  "stream": true
}

但 Engine 不应该直接处理 messages。

Engine 更关心的是:

prompt text
sampling params
stream flag
request id
arrival time

所以 API 层需要做一次转换:

HTTP request body
转成
内部 GenerateRequest

同样,Engine 返回的是内部 RequestOutput。

request_id
delta text
token ids
finished
stop reason

API 层需要把它转换成 OpenAI 风格的 response object 或 stream chunk。

这就是兼容层的核心职责。

它不是模型执行模块。

它不是调度器。

它不是 sampler。

它是协议适配器。

这个边界非常重要。如果 API 层里写了大量推理逻辑,系统会很快变乱。以后如果我们要支持另一个协议,比如兼容某个云厂商 API,或者支持自定义内部 API,就会重复很多逻辑。

更好的结构是:

OpenAI API request
  -> Protocol Adapter
  -> Engine Request
  -> Engine
  -> RequestOutput
  -> Protocol Adapter
  -> OpenAI API response

这样,推理内核和外部协议就解耦了。

Completions 和 Chat Completions 的区别

/v1/completions 是更简单的形式。

它的输入核心是 prompt。

{
  "model": "mini-llama",
  "prompt": "Explain KV cache in LLM inference.",
  "max_tokens": 128,
  "temperature": 0.8,
  "stream": false
}

API 层只需要把 prompt 转成内部请求。

prompt
sampling params
stream flag

然后交给 Engine。

/v1/chat/completions 稍微复杂一些。

它的输入不是一段 prompt,而是一组 messages。

{
  "messages": [
    {
      "role": "system",
      "content": "You are a helpful assistant."
    },
    {
      "role": "user",
      "content": "Explain KV cache."
    }
  ]
}

模型本身通常仍然只接收一段 token 序列。

所以 chat messages 必须先经过 chat template,变成模型实际看到的 prompt。

例如可以转换成类似:

<system>
You are a helpful assistant.
</system>
<user>
Explain KV cache.
</user>
<assistant>

不同模型的 chat template 可能不同。

Llama 系模型、Qwen 系模型、Mistral 系模型、ChatML 风格模型,它们的对话格式都可能不一样。

所以 API 层不能把 chat template 写死。

更合理的做法是让 tokenizer 或 model config 提供 apply_chat_template 之类的能力。

从系统设计上看,Chat Completions 的处理流程是:

messages
经过 chat template
变成 prompt text 或 prompt token ids
进入 Engine

也就是说,Chat Completions 并不是一个完全不同的推理模式。

它只是 prompt 构造方式不同。

一旦 messages 被转换成 prompt,后续仍然走同一套 Sequence、Scheduler、KV cache 和 Sampler。

请求参数如何映射到 SamplingParams

OpenAI 风格请求里会有很多生成参数。

mini vLLM 第一版不需要全部支持,但应该支持最核心的一批。

例如:

{
  "temperature": 0.8,
  "top_p": 0.95,
  "max_tokens": 256,
  "stop": ["\nUser:"],
  "stream": true
}

这些参数可以映射到内部 SamplingParams。

temperature -> temperature
top_p -> top_p
max_tokens -> max_new_tokens
stop -> stop words 或 stop token ids

如果某个参数暂时不支持,API 层不要静默忽略。

静默忽略会让用户误以为参数生效了,实际行为却不同。

更好的方式是明确返回错误,或者在文档里说明兼容范围。

例如第一版可以支持:

model
prompt
messages
max_tokens
temperature
top_p
top_k
stop
stream
n 等于 1

暂时不支持:

function calling
tool calling
response_format
logit_bias
parallel_tool_calls
n 大于 1
beam search
vision input
audio input

这里要注意 n

OpenAI 风格接口可能允许一个请求返回多个候选结果。如果 n 大于 1,内部就不是一个 Request 对应一个 Sequence,而是一个 Request 对应多个 Sequence,也就是我们前面提到的 SequenceGroup。

如果 mini vLLM 当前还没有完整实现 SequenceGroup,那么第一版可以限制 n = 1

这不是问题,关键是明确行为。

兼容层最忌讳的是假装兼容所有字段,但实际只支持其中一部分。

非流式响应如何构造

非流式模式比较简单。

用户请求里:

{
  "stream": false
}

服务层调用 Engine 后,等待请求 finished。

在等待过程中,内部可能已经生成了很多 RequestOutput,但 API 层会把它们累积起来。

最终返回一个完整 response。

对于 Completions,可以返回类似结构:

{
  "id": "cmpl_req_001",
  "object": "text_completion",
  "created": 1710000000,
  "model": "mini-llama",
  "choices": [
    {
      "index": 0,
      "text": "KV cache is ...",
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 32,
    "completion_tokens": 128,
    "total_tokens": 160
  }
}

对于 Chat Completions,可以返回类似结构:

{
  "id": "chatcmpl_req_001",
  "object": "chat.completion",
  "created": 1710000000,
  "model": "mini-llama",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "KV cache is ..."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 32,
    "completion_tokens": 128,
    "total_tokens": 160
  }
}

这里有几个字段需要特别注意。

id 应该是请求级唯一 ID。

created 可以使用 Unix timestamp。

model 返回用户请求中的 model 名称,或者内部实际模型名称。

choices 是列表,即使第一版只支持一个输出,也应该保持列表结构。

finish_reason 要由内部 stop reason 映射而来。

例如:

EOS 或 stop words -> stop
max_tokens -> length
cancelled -> cancelled 或 error

usage 需要统计 prompt tokens、completion tokens 和 total tokens。

这意味着 Engine 或 Sequence 需要记录 token 数。

不要等到 API 层再临时猜。

API 层只做格式转换,不应该重新 tokenizer 统计一遍,除非内部没有保存相关信息。

流式响应的核心是增量 chunk

流式模式更有意思。

用户设置:

{
  "stream": true
}

此时服务端不等待完整结果,而是每生成一点内容就返回一个 chunk。

在 HTTP 层,通常会使用 Server Sent Events。

每个事件可以写成:

data: {...}

最后返回:

data: [DONE]

对于 Chat Completions,流式 chunk 可以包含 delta。

例如第一次可能返回角色:

{
  "id": "chatcmpl_req_001",
  "object": "chat.completion.chunk",
  "created": 1710000000,
  "model": "mini-llama",
  "choices": [
    {
      "index": 0,
      "delta": {
        "role": "assistant"
      },
      "finish_reason": null
    }
  ]
}

后续返回内容增量:

{
  "id": "chatcmpl_req_001",
  "object": "chat.completion.chunk",
  "created": 1710000000,
  "model": "mini-llama",
  "choices": [
    {
      "index": 0,
      "delta": {
        "content": "KV"
      },
      "finish_reason": null
    }
  ]
}

请求结束时返回:

{
  "id": "chatcmpl_req_001",
  "object": "chat.completion.chunk",
  "created": 1710000000,
  "model": "mini-llama",
  "choices": [
    {
      "index": 0,
      "delta": {},
      "finish_reason": "stop"
    }
  ]
}

然后发送 [DONE]

这和第八篇里的异步输出队列正好对应。

Engine 每轮产生 RequestOutput。

流式 handler 从该 request_id 对应的输出队列里取出 delta text。

然后把 delta text 包装成 stream chunk。

也就是说,流式 API 层不应该自己调用模型。它只消费 Engine 的输出队列。

Engine.step 产生 RequestOutput
output_queue 收到 RequestOutput
HTTP stream handler 读取 RequestOutput
转换成 SSE chunk
写回客户端

这个结构能保证多个客户端共享同一个推理引擎,而不是每个 HTTP 请求自己跑一个 generate 循环。

Chat template 是兼容层最容易出错的地方

很多人实现 Chat Completions 兼容时,容易只关注 HTTP response 格式,却忽略 chat template。

但对模型输出来说,chat template 非常关键。

同样的 messages,如果拼接成不同 prompt,模型输出可能完全不同。

例如 messages 是:

[
  {
    "role": "system",
    "content": "You are a helpful assistant."
  },
  {
    "role": "user",
    "content": "Explain KV cache."
  }
]

有些模型期望格式是 ChatML。

有些模型期望特殊的 begin token 和 end token。

有些模型需要在 assistant 开头添加 generation prompt。

如果格式错了,模型可能不认为自己正在扮演 assistant,也可能把用户内容和系统内容混淆。

所以 API 层最好不要手写模板字符串。

更合理的做法是:

tokenizer.apply_chat_template(messages, add_generation_prompt=True)

如果 tokenizer 不支持 chat template,再由 model config 提供一个 fallback template。

同时要在文档中说明:Chat Completions 的质量依赖模型对应的 chat template。

这不是 API 格式问题,而是模型训练格式问题。

一个 OpenAI 风格的接口,不代表所有模型都天然理解 OpenAI 风格的 messages。它们最终仍然要被转换成模型实际训练时见过的 token 格式。

错误响应也要兼容

兼容 API 不能只兼容成功响应。

错误响应同样重要。

真实应用会依赖错误码判断该不该重试、该不该提示用户、该不该降级。

第一版可以设计几类错误。

请求格式错误。

例如缺少 model、缺少 prompt 或 messages、temperature 非法、max_tokens 小于等于 0。

可以返回 400。

模型不存在。

用户请求了一个未加载的 model。

可以返回 404 或 400,取决于你的语义。

资源不足。

例如 KV cache blocks 不足,当前请求无法进入系统。

可以返回 429 或 503。

服务内部错误。

例如模型执行异常。

返回 500。

错误体可以采用类似结构:

{
  "error": {
    "message": "max_tokens must be greater than 0",
    "type": "invalid_request_error",
    "param": "max_tokens",
    "code": "invalid_value"
  }
}

这里的重点不是每个字段必须完全一样,而是错误结构要稳定。

不要有的错误返回纯字符串,有的错误返回 JSON,有的错误直接断开连接。

对于流式请求,如果生成过程中出错,也应该返回一个错误事件或结束流,并在服务端记录 request_id。

这对排查线上问题很重要。

请求取消和客户端断开

流式接口必须处理客户端断开。

用户可能关闭页面,网络可能中断,上游服务可能取消请求。

如果 HTTP 连接已经断开,但 Engine 还在继续 decode,这就是资源浪费。

因为这个请求的输出已经没人消费了,但它仍然占用 KV cache,仍然消耗 GPU。

所以 stream handler 一旦检测到客户端断开,就应该通知 Engine 取消请求。

client disconnect
API layer calls engine.abort(request_id)
Sequence stop_reason = cancelled
Scheduler 不再调度
Block Manager 释放 blocks
output queue 清理

这条路径必须作为正常路径处理,而不是异常情况。

在聊天产品中,用户点击停止生成非常常见。

如果取消处理不好,服务会逐渐积累无效 running 请求,最终影响所有用户。

这也是为什么前面我们给 StopReason 设计了 CANCELLED

API 层的断开事件,最终要映射到内部 Sequence 状态。

usage 统计应该从 Engine 来

OpenAI 风格响应里通常会包含 usage。

{
  "prompt_tokens": 32,
  "completion_tokens": 128,
  "total_tokens": 160
}

这个字段看起来简单,但最好不要在 API 层临时重算。

因为内部引擎最清楚真实 token 数。

对于 Chat Completions,messages 会先经过 chat template,再 tokenize。最终 prompt_tokens 应该是模型实际看到的 token 数,而不是原始 messages 的字符数。

completion_tokens 应该是模型实际生成的 token 数。

如果 stop words 导致输出文本被截断,要明确 completion_tokens 是截断前还是截断后。通常更合理的是记录模型实际生成的 token 数,同时输出文本按 stop words 截断。

这些语义要保持一致。

所以 Sequence 应该记录:

prompt_token_count
generated_token_count
returned_text
stop_reason

API 层只读取这些字段,组装 usage。

对于流式响应,usage 是否在最后一个 chunk 返回,可以作为扩展能力。第一版也可以只在非流式返回 usage。

但内部最好已经具备统计能力。

参数兼容和内部能力之间的差距

OpenAI 风格 API 有很多参数。

但 mini vLLM 第一版不可能全部支持。

这时要处理好“兼容格式”和“兼容能力”的关系。

格式兼容表示请求和响应长得像。

能力兼容表示每个参数语义都完全一致。

第一版可以先做到核心格式兼容,同时明确能力范围。

例如支持:

messages
prompt
temperature
top_p
max_tokens
stop
stream

暂不支持:

tools
function calling
structured output
json schema
logprobs
logit_bias
parallel tool calls
multimodal input

对于暂不支持的字段,有两种策略。

一种是严格模式。

只要用户传了不支持字段,就返回错误。

这种方式更安全,避免误解。

另一种是宽松模式。

忽略未知字段,只处理支持字段。

这种方式更方便接入某些客户端,但可能隐藏问题。

我建议 mini vLLM 默认采用严格模式,同时提供一个兼容模式开关。

严格模式适合开发和调试。

兼容模式适合接入一些会附带额外字段的客户端。

但无论哪种,都应该在日志里记录被忽略的字段。

这样排查行为差异时更容易。

模型名和后端模型的映射

请求里会有 model 字段。

{
  "model": "gpt-compatible-model"
}

在 mini vLLM 中,这个 model 名不一定等于本地模型目录名。

服务端可以维护一个模型注册表。

外部 model name
映射到
内部 model instance

例如:

mini-llama
对应
TinyLlama/TinyLlama-1.1B-Chat-v1.0

这样用户看到的是稳定 API 名称,服务端可以自由调整底层模型路径、量化配置、tensor parallel 配置和 tokenizer。

模型注册表也能支持未来多模型 serving。

一个 server 可以加载多个模型,或者把不同 model name 路由到不同 Engine。

第一版可以只支持单模型,但结构上最好保留 model registry。

当请求 model 不存在时,API 层返回错误。

不要把所有 model 请求都悄悄路由到默认模型。

那会让用户很难发现配置错误。

兼容层如何接入 benchmark

OpenAI API 兼容层也需要 benchmark。

因为 HTTP、JSON 序列化、SSE、detokenization、队列和网络写入都会引入额外开销。

前面的 benchmark 更多关注 Engine 内部。

现在要额外测两类指标。

第一类是协议开销。

同样一个请求,通过内部 Python 调用和通过 HTTP 调用,延迟差多少?

如果差距很大,说明 API 层可能存在序列化、队列或事件循环问题。

第二类是流式稳定性。

在大量并发 HTTP stream 连接下,Engine 是否还能稳定 step?慢客户端是否会拖累推理循环?断开连接是否能及时释放资源?

这些都是在线服务必须关注的问题。

可以记录:

HTTP request parse time
queue wait time
SSE write time
client disconnect count
abort request count
output queue length

API 层不是性能主战场,但它也不能成为瓶颈。

尤其是流式服务中,如果每个 token 都要经过 JSON 序列化和网络写入,开销会被放大。

第一版可以先保证正确,再逐步优化序列化和异步写入。

小结

这一篇我们把 mini vLLM 接到了 OpenAI 风格 API。

这一层的核心职责不是推理,而是协议翻译。

外部请求进入后,API 层把 /v1/completions 的 prompt 或 /v1/chat/completions 的 messages 转成内部 GenerateRequest。Chat messages 需要通过模型对应的 chat template 变成真正的 prompt tokens。随后请求进入 Engine,由 Scheduler、ModelRunner、Block Manager、Sampler 和输出队列完成推理。

非流式请求会累积所有 RequestOutput,最终返回完整 response。

流式请求会把每一轮增量输出转换成 SSE chunk,并在结束时发送 final chunk 和 [DONE]

兼容层还需要处理参数映射、错误响应、usage 统计、客户端断开、请求取消、模型名映射和不支持字段的行为。

第一版不需要完整复刻所有 OpenAI API 能力。更合理的目标是:支持最常用的文本和对话生成字段,明确不支持的参数,并把内部 Engine 和外部协议解耦。

到这里,mini vLLM 已经可以被真实应用调用了。

下一篇,也是这个系列最后一篇,我们会讨论生产部署。

一个推理服务真正上线,还需要处理 Docker 镜像、健康检查、日志、metrics、限流、超时、优雅关闭、模型加载失败、GPU OOM、请求追踪和多副本部署。

这些内容看起来没有 PagedAttention 那么核心,但它们决定了系统能不能稳定运行。