OpenAI 兼容层的口径差异:stop、logprobs 与流式分块
同一份模型、同一组请求参数,分别打到 OpenAI 官方 SDK 与本地 vLLM(v<版本>)的 /v1 端点,返回结构对不上。部署条件是 **** 的 ****(8 卡,单卡 64GB HBM,卡间互联 392 GB/s)加 **** 基础软件栈,vLLM 的 OpenAI 兼容层起服务,--served-model-name 指定对外模型名。现象集中在四处:chat 端点传 logprobs=true 得到的内容结构与 completion 端点不是同一套;stop 命中的字符串从输出里消失;流式响应的第一个分块里没有 content 字段;max_tokens 不传时两个端点采用的默认长度不同。这些差异都落在兼容层的请求模型与响应构造里,与权重、精度无关。
兼容层的实现位置
vllm/entrypoints/openai/protocol.py 定义请求与响应模型,api_server.py 挂路由:/v1/chat/completions、/v1/completions、/v1/models、/v1/embeddings、/tokenize、/detokenize。所有模型继承 OpenAIBaseModel,其 model_config = ConfigDict(extra="forbid") 且注释写明 OpenAI API 不允许额外字段。请求体里出现未声明字段时,pydantic 在进入服务逻辑之前就抛校验错误,由 RequestValidationError 处理函数转成 ErrorResponse(字段为 object / message / type / param / code,code 即返回的 HTTP 状态码)。客户端发送更新版本才有的字段而收到 400,问题就在这一层。
兼容层私有的请求字段
下列字段在请求模型里存在,OpenAI 请求体里没有:
| 字段 | chat 默认值 | completion 默认值 | 语义 |
|---|---|---|---|
use_beam_search |
False |
False |
束搜索开关 |
best_of |
None |
None |
采样候选数,beam search 时须大于 n |
top_k |
-1 |
-1 |
-1 表示关闭 |
min_p |
0.0 |
0.0 |
最小概率阈值 |
repetition_penalty |
1.0 |
1.0 |
计入 prompt 与已生成 token |
length_penalty |
1.0 |
1.0 |
仅 beam search 生效 |
early_stopping |
False |
False |
仅 beam search 生效 |
ignore_eos |
False |
False |
忽略 EOS,按 max_tokens 截断 |
min_tokens |
0 |
0 |
之前不允许 EOS |
include_stop_str_in_output |
False |
False |
停止串是否留在输出 |
truncate_prompt_tokens |
None |
None |
截断 prompt 到该长度 |
skip_special_tokens |
True |
True |
解码时跳过特殊 token |
spaces_between_special_tokens |
True |
True |
特殊 token 间加空格 |
logit_bias |
None |
None |
逐 token 偏置 |
guided_json / guided_regex / guided_choice / guided_grammar |
None |
None |
受控解码 |
echo |
False |
False |
两个端点的语义不同,chat 侧指拼接上一条同角色消息 |
add_generation_prompt / chat_template / documents |
仅 chat | 无 | 仅 chat 端点接受 |
best_of 与 use_beam_search 的约束由 SamplingParams._verify_args 施加:n 至少为 1,beam search 要求 best_of > 1,greedy 采样要求 best_of == 1,否则 best_of must be greater than or equal to n。chat 端点没有针对 beam search 的专属拒绝。logit_bias 的校验只有三条:逐项夹到 [-100, 100]、要求 token id 可解析为整数、越出词表报错,没有条数上限。
stop 与停止串
stop 与 stop_token_ids 由兼容层原样透传,在 SamplingParams 里归一为列表。停止判定的顺序固定:min_tokens 门控、EOS、stop_token_ids、stop 字符串、max_model_len 与 max_tokens;先命中者生效。
停止串是否进入输出由 include_stop_str_in_output 决定,默认 False,命中时截到停止串起始位置,该串不出现在返回文本里;置 True 则截到其末尾并保留。多条 stop 并存时,输出缓冲按 max(len(s) for s in stop) - 1 的长度持有一小段尾部,用于避免半截停止串随流式分块提前外泄。命中的那条会写进响应的 stop_reason 字段,该字段不在 OpenAI 的响应结构里。
logprobs 的两套结构
| 维度 | /v1/completions |
/v1/chat/completions |
|---|---|---|
| 请求字段 | logprobs,类型 int |
logprobs,类型 bool,配 top_logprobs(int,默认 0) |
| 校验 | 无 | top_logprobs 非空时必须 logprobs=true,否则 when using \top_logprobs`, `logprobs` must be set to true.;取负值时 `top_logprobs` must be a value a positive value.` |
| 返回容器 | CompletionLogProbs |
ChatCompletionLogProbs.content |
| 单 token 字段 | tokens / token_logprobs / text_offset / top_logprobs |
token / logprob / bytes / top_logprobs |
completion 侧用 text_offset 标注 token 在原文中的位置;chat 侧改用 bytes 给出 token 的字节表示,没有 text_offset。两边的 top_logprobs 都是「取前 k 个并补齐对齐」的列表,k 由请求给出。
流式分块
- 首块只带
delta.role,content因序列化时exclude_unset被省略,不是空串;role 由--response-role决定,默认assistant。 - 中间块的内容是增量后缀(
delta_text = output.text[len(previous_texts[i]):]),一次可能带多个字符,不保证「一个分块一个 token」。 finish_reason与stop_reason只在最后一块出现,之前的块里恒为None。- usage 只在请求显式带
stream_options时出现。StreamOptions.include_usage在本兼容层默认True(与官方默认不同),因此传一个空的stream_options就会逐块带 usage;完全不传则整条流没有 usage。 - 流的最后一行是
data: [DONE]。
max_tokens 默认值与惩罚项范围
两个端点的默认长度不同:chat 端点的 max_tokens 默认 None,服务端取 max_model_len - len(prompt_token_ids);completion 端点的请求模型里 max_tokens 默认 16,不传就只生成 16 个 token。对照输出长度时必须显式写死 max_tokens,否则两边不可比。
惩罚项的作用范围也有差异。presence_penalty 与 frequency_penalty 只作用于已生成的 token(output_bin_counts、output_mask),不对 prompt 计惩罚;repetition_penalty 计入 prompt 与输出(prompt_mask | output_mask)。response_format.type 只接受 text 与 json_object 两个取值,json_object 走受控解码的 JSON 语法,其他取值由 pydantic 直接拒绝。
触发条件与现象
- 客户端发送官方新版本字段:
extra="forbid"在请求进入服务逻辑前触发 400,返回ErrorResponse,type为BadRequestError。 - chat 传了
top_logprobs却没带logprobs=true:返回校验错误when using \top_logprobs`, `logprobs` must be set to true.`。 - 同时开受控解码与具名工具选择:报
You can only either use guided decoding or tools, not both. - 指定
tool_choice但未提供tools:报When using \tool_choice`, `tools` must be set.;工具非具名时报Currently only named tools are supported.` - 客户端按官方语义读 logprobs:chat 侧期望
tokens与text_offset,实际字段是token与bytes,解析取空。 - 流式首块按官方结构取
choices[0].delta.content:该字段在首块被exclude_unset省略,取到 None。 - 客户端不传
max_tokens打 completion:只收到 16 个 token,被误读为服务端截断。 - 客户端不发
stream_options却期待 usage:整条流不含 usage;反之,发一个空的stream_options就会逐块带 usage。
定位步骤
- 固定请求体。把
temperature设为 0、固定seed与max_tokens,逐步只改一个字段,避免多个差异叠加。 - 分开两个端点取样。同一模型、同一 prompt 分别打
/v1/completions与/v1/chat/completions,比对返回字段名的集合。 - 核对 logprobs 口径。chat 用
logprobs: true加top_logprobs: k,completion 用logprobs: k,确认返回容器与单 token 字段对得上。 - 核对 stop。把
stop设成出现在正文里的短串,分别在include_stop_str_in_output取真、假时各跑一次,确认截断位置与stop_reason。 - 核对流式分块。用 non-stream 与 stream 各跑一次,逐块检查首块是否只有 role、
finish_reason是否只在末块、usage 是否只在带stream_options时出现。 - 核对未知字段。在请求体里加入一个未声明字段,观察是否返回 400 与
ErrorResponse,确认extra="forbid"生效。
# 对照两个端点的返回字段(endpoint 与端口按部署填,请求体见上面的字段表)
curl -s -X POST <endpoint>:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"<served-name>","messages":[{"role":"user","content":"..."}],"logprobs":true,"top_logprobs":2,"stop":["\n\n"],"stream":true,"stream_options":{"include_usage":true}}'
curl -s -X POST <endpoint>:8000/v1/completions \
-H 'Content-Type: application/json' \
-d '{"model":"<served-name>","prompt":"...","logprobs":2,"max_tokens":16}'
记录字段
# 一条兼容层对照记录(字段名示意)
endpoint: # /v1/completions 或 /v1/chat/completions
served_model_name:
stream: # 是否流式
request:
logprobs: # completion 是 int,chat 是 bool
top_logprobs: # 仅 chat
stop: # 停止串列表
stop_token_ids:
include_stop_str_in_output:
max_tokens: # 必须显式给定
stream_options: # 是否带 include_usage
response:
logprobs_container: # CompletionLogProbs / ChatCompletionLogProbs
token_fields: # tokens+text_offset 或 token+bytes
stop_reason: # 兼容层加出的字段
first_chunk_has_content: # 首块是否带 content
usage_present: # 是否出现 usage
error:
unknown_field_rejected: # extra="forbid" 是否触发 400
status: # 两端口径是否可比
小结
兼容层的差异集中在四处:请求模型继承 OpenAIBaseModel 并对未知字段 extra="forbid",一批 OpenAI 没有的字段(use_beam_search、top_k、min_p、ignore_eos、min_tokens、include_stop_str_in_output、truncate_prompt_tokens、guided_* 等)在此可用;stop 的判定顺序与 include_stop_str_in_output 决定停止串是否留在输出,命中记入 stop_reason;logprobs 在 completion 是 int、在 chat 是 bool 配 top_logprobs,两边的单 token 字段不同;流式首块只有 role,finish_reason 只在末块,usage 只在带 stream_options 时出现。max_tokens 的默认值两端也不同,completion 默认 16、chat 取上下文余量。对照结论前,先把请求体逐字段固定。