执行摘要
- 一句话:为render服务器添加/derender端点完成解聚后处理
- 推荐动作:推荐精读
vllm/entrypoints/serve/render/serving.py和vllm/entrypoints/openai/engine/serving.py,理解设计决策:如何在保持generate server无tokenizer的情况下实现端到端文本响应。特别是format_token_id_placeholder/resolve_token_id_placeholder的对称设计值得参考。审查中关于代码合并与解耦的讨论也具有学习价值。
功能与动机
遵循RFC #42729,解聚服务将render(预处理)和generate(推理)分离到不同服务器。generate服务器为保持轻量和GPU-only,不携带tokenizer,因此无法直接输出文本。derender端点作为后处理,在render服务器上闭环,填补了生成输出到标准响应格式的缺失环节。
实现拆解
- 协议模型(
vllm/entrypoints/serve/disagg/protocol.py):新增DerenderChatRequest和DerenderCompletionRequest,封装GenerateResponse、可选的prompt_tokens计数以及原始请求对象(供未来解析器使用)。DerenderCompletionRequest利用pydantic model_validator校验prompt_tokens长度与generate_responses一致。
- 辅助函数提取(
vllm/entrypoints/openai/engine/serving.py):将format_token_id_placeholder和resolve_token_id_placeholder从render服务器私有实现中提取为公共函数。前者将token ID格式化为token_id:N字符串;后者解码占位符,通过tokenizer.convert_ids_to_tokens和tokenizer.convert_tokens_to_string正确获取文本,并编码为UTF-8字节。同时改造_get_decoded_token使用此函数,保持一致性。
- 核心derender逻辑(
vllm/entrypoints/serve/render/serving.py):在OpenAIServingRender类中新增derender_chat_response和derender_completion_response方法。辅助函数_resolve_logprobs遍历logprob条目解析每个token占位符;_convert_chat_logprobs_to_completion_logprobs适配Completion响应schema;_build_chat_choice构建单个choice。主方法从GenerateResponse中提取每个choice,detokenize得到文本,解析logprobs,组装完整的ChatCompletionResponse/CompletionResponse。
- API路由(
vllm/entrypoints/serve/render/api_router.py):添加POST /v1/chat/completions/derender和POST /v1/completions/derender,使用相同的validate_json_request依赖和错误处理模式,调用handler对应方法。
- 现有serving路径适配(
vllm/entrypoints/openai/chat_completion/serving.py和completion/serving.py):将硬编码的f'token_id:{token_id}'替换为format_token_id_placeholder函数调用,统一占位符格式。
- 测试配套(
tests/entrypoints/serve/render/test_derender.py):新增488行测试,覆盖chat和completion的roundtrip、usage计数、logprob解析、输入验证等场景,使用RemoteLaunchRenderServer启动真实render服务器进行集成测试。
关键文件:
vllm/entrypoints/serve/render/serving.py(模块 渲染服务;类别 source;类型 core-logic;符号 _resolve_logprobs, _convert_chat_logprobs_to_completion_logprobs, _build_chat_choice, derender_chat_response): 核心实现:包含derender_chat_response、derender_completion_response方法以及辅助函数_resolve_logprobs、_convert_chat_logprobs_to_completion_logprobs、_build_chat_choice。新增244行,是整个derender服务的业务逻辑所在。
tests/entrypoints/serve/render/test_derender.py(模块 集成测试;类别 test;类型 test-coverage;符号 server, client, _render_chat, _make_generate_response): 新增488行集成测试,覆盖chat和completion derender的roundtrip、usage、logprobs解析等场景,是验证新功能正确性的核心测试。
vllm/entrypoints/serve/disagg/protocol.py(模块 协议层;类别 source;类型 data-contract;符号 DerenderChatRequest, DerenderCompletionRequest, _validate_prompt_tokens_length): 定义DerenderChatRequest和DerenderCompletionRequest数据模型,是derender API的协议基础。同时新增模型验证器确保prompt_tokens长度一致性。
vllm/entrypoints/serve/render/api_router.py(模块 路由层;类别 source;类型 entrypoint;符号 derender_chat_completion, derender_completion): 注册两个新HTTP路由/v1/chat/completions/derender和/v1/completions/derender,将请求分发到OpenAIServingRender的对应方法。
vllm/entrypoints/openai/engine/serving.py(模块 引擎服务;类别 source;类型 core-logic;符号 format_token_id_placeholder, resolve_token_id_placeholder): 提取format_token_id_placeholder和resolve_token_id_placeholder作为公共函数,供render、chat completion、completion多个模块使用,确保占位符格式统一。
vllm/entrypoints/openai/chat_completion/serving.py(模块 聊天服务;类别 source;类型 core-logic): 将硬编码的token_id占位符字符串替换为format_token_id_placeholder函数调用,与derender路径保持一致的placeholder格式。
vllm/entrypoints/openai/completion/serving.py(模块 补全服务;类别 source;类型 core-logic): 与chat_completion/serving.py类似,将硬编码的token_id占位符替换为format_token_id_placeholder调用。
关键符号: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
核心实现:包含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
定义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
提取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 resolution正确性(gemini-code-assist[bot]):指出
convert_ids_to_tokens返回内部表示(如、Ġ),直接.encode('utf-8')产生错误bytes。作者修正为先用convert_tokens_to_string获取真实文本再编码。
- 代码维护(DarkLight1337):要求合并derender与现有serving路径的logprob构建逻辑,避免drift。作者权衡后坚持当前设计:generate服务器无tokenizer,协议传递占位符更干净;若改为传递原始Logprob数据会增加网络开销并破坏无tokenizer假设。审查者接受此解释。
- 辅助函数风格(DarkLight1337):建议使用
removeprefix替代startswith+切片;使用warning_once避免重复日志。作者均采纳。
- 日志等级(DarkLight1337):提醒derender端点的日志可能频繁触发,作者改为
debug级别。
- token_id占位符解码正确性 (correctness): 作者采纳并修复为使用convert_tokens_to_string。
- derender与现有serving代码合并 (design): 维持当前设计,不合并。
- 代码风格:removeprefix和warning_once (style): 作者采纳,代码已修改。
- 日志级别过高 (performance): 作者改为debug。
风险与影响
- 风险:
- Token resolution缺陷:已合并后报告了U+FFFD替换字符问题(issue comment #43606#issuecomment-),表明
resolve_token_id_placeholder在处理非ASCII token时可能产生错误字节。aoshen02正在PR #45919中修复。
- 同步阻塞:derender端点是同步且无状态,如果请求量大,可能阻塞render服务器的异步事件循环。虽然有
run_in_executor的潜在改进,但当前未实现。
- 代码维护:
_resolve_logprobs、_convert_chat_logprobs_to_completion_logprobs等函数与chat_completion/serving.py中的logprob构建逻辑有重叠,存在维护不一致的风险。
- 协议耦合:
DerenderChatRequest已包含ChatCompletionRequest字段但尚未被解析器使用,未来协议调整可能涉及多PR联动。
- 影响:对用户:提供了解聚部署场景下完整的前后端分离能力,用户可通过vllm launch render同时得到render和derender端点。对系统:render服务器现在承担后处理CPU负载,但无需GPU;请求延迟增加一个心跳(detokenize+解析)。对团队:新增488行测试,维护责任明确;但代码与现有serving路径有重叠,需在后续重构中关注统一。
- 风险标记:token resolution缺陷, 同步阻塞, 代码维护风险, 协议耦合
关联脉络
- PR #42433 [EC Connector] Add EC Transfer Params: 共享disaggregated serving架构,EC Connector负责跨服务器传输,derender负责后处理,共同构成解聚服务完整流程。
- PR #48102 [Bugfix][KV Offloading] Fix stale transfer_jobs after reset_cache + harden job completion: KV offloading也属于解聚组件,修复可能影响derender的竞态条件,体现同一功能域的维护活动。
参与讨论