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 由请求给出。

流式分块

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 直接拒绝。

触发条件与现象

定位步骤

  1. 固定请求体。把 temperature 设为 0、固定 seed 与 max_tokens,逐步只改一个字段,避免多个差异叠加。
  2. 分开两个端点取样。同一模型、同一 prompt 分别打 /v1/completions 与 /v1/chat/completions,比对返回字段名的集合。
  3. 核对 logprobs 口径。chat 用 logprobs: true 加 top_logprobs: k,completion 用 logprobs: k,确认返回容器与单 token 字段对得上。
  4. 核对 stop。把 stop 设成出现在正文里的短串,分别在 include_stop_str_in_output 取真、假时各跑一次,确认截断位置与 stop_reason。
  5. 核对流式分块。用 non-stream 与 stream 各跑一次,逐块检查首块是否只有 role、finish_reason 是否只在末块、usage 是否只在带 stream_options 时出现。
  6. 核对未知字段。在请求体里加入一个未声明字段,观察是否返回 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 取上下文余量。对照结论前,先把请求体逐字段固定。