Prhub

#43606 [Render] Add `/derender` endpoints for disaggregated postprocessing

原始 PR 作者 hickeyma 合并时间 2026-06-13 13:55 文件变更 7 提交数 9 评论 25 代码增减 +898 / -9

执行摘要

为 render 服务器添加 /derender 端点完成解聚后处理

遵循RFC #42729,解聚服务将render(预处理)和generate(推理)分离到不同服务器。generate服务器为保持轻量和GPU-only,不携带tokenizer,因此无法直接输出文本。derender端点作为后处理,在render服务器上闭环,填补了生成输出到标准响应格式的缺失环节。

推荐精读vllm/entrypoints/serve/render/serving.pyvllm/entrypoints/openai/engine/serving.py,理解设计决策:如何在保持generate server无tokenizer的情况下实现端到端文本响应。特别是format_token_id_placeholder/resolve_token_id_placeholder的对称设计值得参考。审查中关于代码合并与解耦的讨论也具有学习价值。

讨论亮点
  1. token resolution正确性(gemini-code-assist[bot]):指出convert_ids_to_tokens返回内部表示(如Ġ),直接.encode('utf-8')产生错误bytes。作者修正为先用convert_tokens_to_string获取真实文本再编码。
  2. 代码维护(DarkLight1337):要求合并derender与现有serving路径的logprob构建逻辑,避免drift。作者权衡后坚持当前设计:generate服务器无tokenizer,协议传递占位符更干净;若改为传递原始Logprob数据会增加网络开销并破坏无tokenizer假设。审查者接受此解释。
  3. 辅助函数风格(DarkLight1337):建议使用removeprefix替代startswith+切片;使用warning_once避免重复日志。作者均采纳。
  4. 日志等级(DarkLight1337):提醒derender端点的日志可能频繁触发,作者改为debug级别。

实现拆解

  1. 协议模型vllm/entrypoints/serve/disagg/protocol.py):新增DerenderChatRequestDerenderCompletionRequest,封装GenerateResponse、可选的prompt_tokens计数以及原始请求对象(供未来解析器使用)。DerenderCompletionRequest利用pydantic model_validator校验prompt_tokens长度与generate_responses一致。
  2. 辅助函数提取vllm/entrypoints/openai/engine/serving.py):将format_token_id_placeholderresolve_token_id_placeholder从render服务器私有实现中提取为公共函数。前者将token ID格式化为token_id:N字符串;后者解码占位符,通过tokenizer.convert_ids_to_tokenstokenizer.convert_tokens_to_string正确获取文本,并编码为UTF-8字节。同时改造_get_decoded_token使用此函数,保持一致性。
  3. 核心derender逻辑vllm/entrypoints/serve/render/serving.py):在OpenAIServingRender类中新增derender_chat_responsederender_completion_response方法。辅助函数_resolve_logprobs遍历logprob条目解析每个token占位符;_convert_chat_logprobs_to_completion_logprobs适配Completion响应schema;_build_chat_choice构建单个choice。主方法从GenerateResponse中提取每个choice,detokenize得到文本,解析logprobs,组装完整的ChatCompletionResponse/CompletionResponse
  4. API路由vllm/entrypoints/serve/render/api_router.py):添加POST /v1/chat/completions/derenderPOST /v1/completions/derender,使用相同的validate_json_request依赖和错误处理模式,调用handler对应方法。
  5. 现有serving路径适配vllm/entrypoints/openai/chat_completion/serving.pycompletion/serving.py):将硬编码的f'token_id:{token_id}'替换为format_token_id_placeholder函数调用,统一占位符格式。
  6. 测试配套tests/entrypoints/serve/render/test_derender.py):新增488行测试,覆盖chat和completion的roundtrip、usage计数、logprob解析、输入验证等场景,使用RemoteLaunchRenderServer启动真实render服务器进行集成测试。
文件 模块 状态 重要度
vllm/entrypoints/serve/render/serving.py 渲染服务 modified 8.84
tests/entrypoints/serve/render/test_derender.py 集成测试 added 7.76
vllm/entrypoints/serve/disagg/protocol.py 协议层 modified 7.98
vllm/entrypoints/serve/render/api_router.py 路由层 modified 7.65
vllm/entrypoints/openai/engine/serving.py 引擎服务 modified 7.43
vllm/entrypoints/openai/chat_completion/serving.py 聊天服务 modified 4.59
vllm/entrypoints/openai/completion/serving.py 补全服务 modified 4.59

关键符号

format_token_id_placeholder resolve_token_id_placeholder _resolve_logprobs _convert_chat_logprobs_to_completion_logprobs _build_chat_choice derender_chat_response derender_completion_response derender_chat_completion derender_completion _validate_prompt_tokens_length

关键源码片段

vllm/entrypoints/serve/render/serving.py core-logic

核心实现:包含 derender_chat_response、derender_completion_response 方法以及辅助函数 _resolve_logprobs、_convert_chat_logprobs_to_completion_logprobs、_build_chat_choice。新增 244 行,是整个 derender 服务的业务逻辑所在。

# 逐个解析 ChatCompletionLogProbs 中的 token_id:N 占位符
def _resolve_logprobs(
    logprobs: ChatCompletionLogProbs, tokenizer: TokenizerLike
) -> ChatCompletionLogProbs:
    # 如果没有 content 则直接返回
    if logprobs.content is None:
        return logprobs
    resolved_content = []
    for entry in logprobs.content:
        # 解析当前 token 的占位符,返回真实字符串和 UTF-8 bytes
        token_str, token_bytes = resolve_token_id_placeholder(entry.token, tokenizer)
        # 同时解析 top_logprobs 中每个候选 token
        resolved_top = []
        for top in entry.top_logprobs:
            top_str, top_bytes = resolve_token_id_placeholder(top.token, tokenizer)
            resolved_top.append(
                top.model_copy(update={"token": top_str, "bytes": top_bytes})
            )
        resolved_content.append(
            entry.model_copy(
                update={
                    "token": token_str,
                    "bytes": token_bytes,
                    "top_logprobs": resolved_top,
                }
            )
        )
    return ChatCompletionLogProbs(content=resolved_content)
​
​
# 将 ChatLogProbs(per-token 对象)转换为 CompletionLogProbs(并行扁平列表)
def _convert_chat_logprobs_to_completion_logprobs(
    logprobs: ChatCompletionLogProbs,
) -> CompletionLogProbs:
    if logprobs.content is None:
        return CompletionLogProbs()
    tokens: list[str] = []
    token_logprobs: list[float | None] = []
    top_logprobs_list: list[dict[str, float] | None] = []
    text_offset: list[int] = []
    offset = 0
    for entry in logprobs.content:
        text_offset.append(offset)
        tokens.append(entry.token)
        token_logprobs.append(entry.logprob)
        top_logprobs_list.append(
            {t.token: t.logprob for t in entry.top_logprobs}
            if entry.top_logprobs
            else None
        )
        offset += len(entry.token)
    return CompletionLogProbs(
        text_offset=text_offset,
        token_logprobs=token_logprobs,
        tokens=tokens,
        top_logprobs=top_logprobs_list,
    )
vllm/entrypoints/serve/disagg/protocol.py data-contract

定义 DerenderChatRequest 和 DerenderCompletionRequest 数据模型,是 derender API 的协议基础。同时新增模型验证器确保 prompt_tokens 长度一致性。

class DerenderChatRequest(BaseModel):
    '''Request for the /v1/chat/completions/derender endpoint.    Wraps a GenerateResponse and caller-supplied metadata needed to produce
    a fully-formed ChatCompletionResponse without a GPU.
    '''
    model: str
    generate_response: GenerateResponse
    prompt_tokens: int | None = None # prompt token 数,用于 usage
    chat_request: ChatCompletionRequest | None = None # 原始请求,供 parser 使用
​
​
class DerenderCompletionRequest(BaseModel):
    '''Request for the /v1/completions/derender endpoint.    Parallel to DerenderChatRequest but handles multi-prompt completions.
    '''
    model: str
    generate_responses: list[GenerateResponse]
    prompt_tokens: list[int] | None = None # 每个 response 的 prompt token 数
    completion_request: CompletionRequest | None = None
​
    @model_validator(mode='after')
    def _validate_prompt_tokens_length(self) -> 'DerenderCompletionRequest':
        # 确保 prompt_tokens 长度与 generate_responses 一致
        if self.prompt_tokens is not None and len(self.prompt_tokens) != len(
            self.generate_responses
        ):
            raise ValueError(
                f'prompt_tokens length ({len(self.prompt_tokens)}) must equal '
                f'generate_responses length ({len(self.generate_responses)})'
            )
        return self
vllm/entrypoints/openai/engine/serving.py core-logic

提取 format_token_id_placeholder 和 resolve_token_id_placeholder 作为公共函数,供 render、chat completion、completion 多个模块使用,确保占位符格式统一。

def resolve_token_id_placeholder(
    token: str, tokenizer: TokenizerLike
) -> tuple[str, list[int] | None]:
    '''Decode a 'token_id:N' placeholder back to a token string and UTF-8 bytes.    Returns (token, None) unchanged if token is not a placeholder.
    This is the inverse of format_token_id_placeholder / _get_decoded_token
    when return_as_token_id=True.
    '''
    # 尝试移除前缀 'token_id:',若 token 不以前缀开头则返回原值
    suffix = token.removeprefix('token_id:')
    if suffix == token:
        return token, None
    try:
        token_id = int(suffix)
    except ValueError:
        return token, None
    # 通过 tokenizer 获取 token 的内部表示
    token_repr = tokenizer.convert_ids_to_tokens([token_id])[0]
    if token_repr is None:
        logger.warning_once(
            'resolve_token_id_placeholder: token_id %d has no vocab entry; '
            'substituting empty string',
            token_id,
        )
        return '', None
    # 将内部表示转换为真实文本字符串
    token_str = tokenizer.convert_tokens_to_string([token_repr])
    # 编码为 UTF-8 字节序列(errors='replace' 避免非 UTF-8 数据崩溃)
    return token_str, list(token_str.encode('utf-8', errors='replace'))

评论区精华

token_id 占位符解码正确性 正确性

gemini-code-assist[bot] 指出 convert_ids_to_tokens 返回的是内部表示(如 ` `、`Ġ`),直接 `.encode('utf-8')` 产生错误 bytes。应先用 convert_tokens_to_string 获取真实文本再编码。

结论:作者采纳并修复为使用 convert_tokens_to_string。 · 已解决

derender 与现有 serving 代码合并 设计

DarkLight1337 询问是否可复用现有 logprob 构建逻辑避免 drift。作者解释:generate 服务器无 tokenizer,协议传递占位符更干净;若改为传递原始 Logprob 数据会增加网络开销并破坏无 tokenizer 假设。审查者接受。

结论:维持当前设计,不合并。 · 已解决

代码风格:removeprefix 和 warning_once style

DarkLight1337 建议使用 removeprefix 替代 startswith+ 切片解析 token_id: 前缀,使用 warning_once 避免重复日志警告。

结论:作者采纳,代码已修改。 · 已解决

日志级别过高 性能

DarkLight1337 指出 derender 端点日志可能频繁打印,建议改为 debug 级别避免性能开销。

结论:作者改为 debug。 · 已解决

风险与影响

  1. Token resolution缺陷:已合并后报告了U+FFFD替换字符问题(issue comment #43606#issuecomment-),表明resolve_token_id_placeholder在处理非ASCII token时可能产生错误字节。aoshen02正在PR #45919中修复。
  2. 同步阻塞:derender端点是同步且无状态,如果请求量大,可能阻塞render服务器的异步事件循环。虽然有run_in_executor的潜在改进,但当前未实现。
  3. 代码维护_resolve_logprobs_convert_chat_logprobs_to_completion_logprobs等函数与chat_completion/serving.py中的logprob构建逻辑有重叠,存在维护不一致的风险。
  4. 协议耦合DerenderChatRequest已包含ChatCompletionRequest字段但尚未被解析器使用,未来协议调整可能涉及多PR联动。

对用户:提供了解聚部署场景下完整的前后端分离能力,用户可通过vllm launch render同时得到render和derender端点。对系统:render服务器现在承担后处理CPU负载,但无需GPU;请求延迟增加一个心跳(detokenize+解析)。对团队:新增488行测试,维护责任明确;但代码与现有serving路径有重叠,需在后续重构中关注统一。

token resolution 缺陷 同步阻塞 代码维护风险 协议耦合

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论