Prhub

#30917 Add return_token_ids support to completions and chat completions APIs

原始 PR 作者 Jiminator 合并时间 2026-07-24 05:41 文件变更 6 提交数 9 评论 9 代码增减 +159 / -19

执行摘要

OpenAI 端点新增 return_token_ids,返回原始 token ids

PR body 明确指出:客户端消费 /v1/completions 做代码补全(FIM / tab-completion)时,需要生成的精确 token ids 而不是只能拿到字符串;在客户端对返回文本重新分词会与模型实际采样结果产生漂移(retokenizing the returned text client-side can drift from what the model actually sampled)。同样,/v1/chat/completions 的 agent RL 场景也需要精确 ids。此前 SGLang 的 OpenAI 兼容端点没有该能力,只有 chat 端暴露了 prompt 侧的 return_prompt_token_ids。

值得精读。核心看点有三个:一是流式场景下累积/增量两种模式下 token ids 与文本 delta 的对齐处理(n_prev_token_ids 切片 vs 直接透传),二是顺带修复的增量流式文本二次切片 bug——这是新测试暴露存量缺陷的典型案例,三是协议层用 model_serializer 在 None 时剔除字段以保持默认响应不变的扩展方式。若你有 chat 流式 token ids 需求,可关注作者提出的"原始 token 流独立于解析文本流返回"的后续方案。

讨论亮点

核心讨论有两条:

  1. shadeMe 询问为什么 chat 端点流式时不能返回 prompt/output token ids("Out of curiosity - what prevents the chat completions endpoint from returning the prompt/output token IDs when streaming?")。Jiminator 解释:chat 流式把 delta 送入 reasoning 与 tool-call 解析器,这些解析器会抑制、拆分或改写文本,导致 token ids 无法与每个解析后的文本块可靠对齐;干净的做法是后续把原始 token ids 作为独立于解析文本的流返回,但本 PR 刻意控制在非流式。shadeMe 确认 /v1/completions 流式已覆盖其主场景,无需阻塞。

  2. zyzshishui 指出已存在变通方案:non-streaming chat 下用 logprobs=True + return_prompt_token_ids + return_meta_info 可从 meta_info.output_token_logprobs 中提取精确 ids。Jiminator 与评论者都认可该变通方案不使本 PR 冗余:变通方案强制开启 logprob 元数据、不覆盖 /v1/completions、且这些开关在流式下被拒绝。

实现拆解

实现拆解分五步:

  1. 协议层新增字段(python/sglang/srt/entrypoints/openai/protocol.py):在 CompletionRequest 与 ChatCompletionRequest 上新增 return_token_ids: bool = False;在 CompletionResponseChoice、CompletionResponseStreamChoice、ChatCompletionResponseChoice 上新增可选字段 token_idsprompt_token_ids,并通过 _serialize(model_serializer mode=wrap)在字段为 None 时从响应中剔除,确保未请求时默认响应与 OpenAI 标准格式完全一致。这是整个功能的对外契约。

  2. /v1/completions 非流式支持(serving_completions.py):在 _convert_to_internal_request 中把 request.return_token_ids 直接映射到 GenerateReqInput.return_prompt_token_ids,复用引擎侧已有的 prompt ids 捕获逻辑;在 _build_completion_response 中按请求开关填充 ret_item["output_ids"]ret_item.get("prompt_token_ids")

  3. /v1/completions 流式支持(serving_completions.py):在 _generate_completion_stream 中新增 n_prev_token_ids 字典按 choice index 记录已发出数量:非增量模式下 output_ids 是累积的,需要切片 output_ids[n_prev_token_id:] 得到本 chunk 新增 ids;增量模式下 output_ids 本身就是本 chunk 的新增 ids,直接透传。prompt_token_ids 只在每个 choice 的第一个 chunk 上附加。

  4. /v1/chat/completions 支持(serving_chat.py)_convert_to_internal_requeststream=true 时对 return_token_ids 抛出明确 ValueError;引擎开关改为 request.return_prompt_token_ids or request.return_token_ids_build_chat_response 回填解析前原始 output_ids 作为 token_ids。

  5. 顺带修复与测试配套:修复 --incremental-streaming-output 下 completions 文本 delta 被按累积偏移二次切片的问题(增量模式下 delta = text 直接使用后端增量);测试覆盖三个文件:test_serving_completions.py 新增流式 token ids 增量拼接恰好等于完整输出的属性测试(两种流式模式、prompt_token_ids 只在首 chunk)、test_serving_chat.py 参数化流式拒绝与 token ids 回填断言、test_protocol.py 补充 token_ids 序列化省略断言。

文件 模块 状态 重要度
python/sglang/srt/entrypoints/openai/serving_completions.py 补全服务 modified 6.58
python/sglang/srt/entrypoints/openai/protocol.py 协议层 modified 6.37
python/sglang/srt/entrypoints/openai/serving_chat.py 对话服务 modified 6.21
test/registered/unit/entrypoints/openai/test_serving_completions.py 补全测试 modified 6.86
test/registered/unit/entrypoints/openai/test_serving_chat.py 对话测试 modified 6.09
test/registered/unit/entrypoints/openai/test_protocol.py 协议测试 modified 3.52

关键符号

_convert_to_internal_request _generate_completion_stream _build_completion_response _build_chat_response _serialize

关键源码片段

python/sglang/srt/entrypoints/openai/serving_completions.py core-logic

核心实现文件:同时承载流式与非流式 token ids 回填、累积 / 增量双模式 chunk delta 处理,以及增量流式文本二次切片 bug 的修复。

        # ---- 流式 chunk 的 token_ids 提取 ----
        chunk_token_ids = None
        chunk_prompt_token_ids = None
        if request.return_token_ids:
            output_ids = content["output_ids"]
            if not self.tokenizer_manager.server_args.incremental_streaming_output:
                # 非增量模式下 output_ids 是累积的(例如 [5, 5, 6, 5, 6, 7]),
                # 需要用 n_prev_token_ids 记录上一 chunk 已发出的数量再做切片,
                # 保证每个 chunk 只携带本段新增的 token ids。
                n_prev_token_id = n_prev_token_ids.get(index, 0)
                chunk_token_ids = output_ids[n_prev_token_id:]
                n_prev_token_ids[index] = len(output_ids)
            else:
                # 增量模式下 output_ids 本身就是本 chunk 的新增 ids,直接透传
                chunk_token_ids = output_ids
            if is_first_chunk:
                # prompt_token_ids 只挂在每个 choice 的第一个 chunk 上
                chunk_prompt_token_ids = content.get("prompt_token_ids")
​
        # ---- 文本 delta 生成(含增量流式修复) ----
        if self.tokenizer_manager.server_args.incremental_streaming_output:
            # 后端在增量模式下返回的 text 已是增量,若仍按累积偏移
            # text[offset:] 二次切片,会把文本重复截断导致损坏(本 PR 顺带修复)
            delta = text
        else:
            delta = text[offset:]
        stream_offsets[index] = len(content["text"])
python/sglang/srt/entrypoints/openai/protocol.py core-logic

对外契约文件:定义请求参数与响应字段,以及 None 即省略的序列化约定,是默认响应保持不变的保证。

class CompletionResponseChoice(BaseModel):
    index: int
    text: str
    logprobs: Optional[LogProbs] = None
    finish_reason: Optional[Literal["stop", "length", "content_filter", "abort"]] = None
    matched_stop: Union[None, int, str] = None
    hidden_states: Optional[object] = None
    # return_token_ids=true 时才填充,否则保持 None 并在序列化时剔除
    token_ids: Optional[List[int]] = None
    prompt_token_ids: Optional[List[int]] = None
​
    @model_serializer(mode="wrap")
    def _serialize(self, handler):
        data = handler(self)
        # 所有可选扩展字段在 None 时从响应中移除,
        # 保证默认响应与 OpenAI 标准格式保持一致
        if self.hidden_states is None:
            data.pop("hidden_states", None)
        if self.token_ids is None:
            data.pop("token_ids", None)
        if self.prompt_token_ids is None:
            data.pop("prompt_token_ids", None)
        return data
python/sglang/srt/entrypoints/openai/serving_chat.py core-logic

chat 端点实现:流式组合显式拒绝、引擎开关合并,以及原始采样 ids 回填,保证 token_ids 语义在解析前。

        if request.stream:
            if request.return_prompt_token_ids:
                raise ValueError(
                    "return_prompt_token_ids is not supported with streaming. "
                    "Please set stream=false when using return_prompt_token_ids=true."
                )
            if request.return_token_ids:
                # 流式 chat 的 delta 会经过 reasoning/tool-call 解析器,
                # 文本可能被抑制、拆分或改写,raw token ids 无法与解析后的
                # 文本块对齐,因此只在非流式下开放该能力;作者建议后续以
                # 独立的原始 token 流补充(token 流与解析文本流并行返回)
                raise ValueError(
                    "return_token_ids is not supported with streaming on "
                    "/v1/chat/completions. Please set stream=false when using "
                    "return_token_ids=true."
                )
​
        # ---- _build_chat_response 中回填响应 ----
        # 两个开关任一开启即回传 prompt_token_ids
        choice_prompt_token_ids = (
            ret_item.get("prompt_token_ids")
            if request.return_prompt_token_ids or request.return_token_ids
            else None
        )
        # token_ids 是未经 reasoning/tool-call 解析的原始采样 ids,
        # 调用方需自行处理与解析后 message.content 的对应关系
        choice_token_ids = ret_item["output_ids"] if request.return_token_ids else None

评论区精华

chat 流式端点为何不支持 token ids 返回 设计

shadeMe 询问流式 chat 不能返回 prompt/output token ids 的技术原因;Jiminator 解释流式 chat 的 delta 会经过 reasoning 与 tool-call 解析器,解析器可能抑制、拆分或改写文本,导致 token ids 无法与每个解析后的文本块可靠对齐;干净的后续方案是把原始 token ids 作为独立于解析文本的流返回。

结论:本 PR 刻意将 chat 限定为非流式;作者表达了做独立 token 流 follow-up 的意愿,shadeMe 确认 /v1/completions 流式已满足其主场景。 · 已解决

logprobs+meta_info 变通方案是否使本 PR 冗余 设计

zyzshishui 指出非流式 chat 可用 logprobs=True + return_prompt_token_ids + return_meta_info,从 meta_info.output_token_logprobs 提取精确 ids,并论证该变通方案不使 PR 冗余:需强制开启 logprob 元数据、不覆盖 /v1/completions、且相关开关在流式下被拒绝。

结论:社区认可变通方案的局限性与本 PR 的增量价值,无代码层面修改。 · 已解决

风险与影响

  1. 依赖引擎响应字段存在:serving_completions.py 与 serving_chat.py 在 return_token_ids=true 时直接读取 content["output_ids"] / ret_item["output_ids"],若某些引擎路径(如多模态、特殊模型或旧版本 tokenizer-manager)的响应未携带 output_ids 字段,会抛 KeyError;现有测试均用 mock 数据,未覆盖真实引擎全路径。
  2. 增量流式文本行为修复:--incremental-streaming-output 下 /v1/completions 的文本从损坏变为正确,属于修复,但已按旧(错误)行为做基线对齐的上游客户端可能感知到输出变化。
  3. chat token_ids 语义易误用:非流式 chat 的 token_ids 是经过 reasoning/tool-call 解析之前的原始 ids,与 message.content / reasoning_content 不对齐;调用方若按 ids 去逐 token 拼接解析后的文本会得到错误关联,PR body 已说明但缺少面向用户的文档。
  4. 多 choice 流式边界:n_prev_token_ids 按 index 独立跟踪,但测试未覆盖 n>1、prompt 列表等并发多 choice 场景下 chunk 乱序或 index 复用的切片正确性。

影响集中在 OpenAI 兼容适配层(protocol.py、serving_completions.py、serving_chat.py),不涉及引擎、调度或 tokenizer 核心路径,因此无推理性能影响。对用户侧,FIM/代码补全与 agent RL 客户端可以在不额外开启 logprobs 的情况下拿到精确 token ids,避免二次分词漂移;默认响应零变化,向后兼容。对团队侧,协议层"None 即省略"的序列化模式进一步确立了 SGLang 扩展字段的标准写法,流式下"每 chunk 返回增量 ids、prompt ids 只在首 chunk"的语义可以作为后续端点扩展的参考契约。

依赖引擎 output_ids 字段存在 增量流式文本行为修复 chat 流式限制需文档化 多 choice 流式边界未覆盖

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论