跳到正文
Prime Intellect Blog·· 3 小时前AI 评分62

Prime Intellect 开源 renderers:面向智能体 RL 的 Token 级模板库

renderers: Token-Level Templating for Agentic RL

AI 导读

Prime Intellect 开源 Python 库 renderers,让开发者完全控制 RL 与多轮推理中的对话格式化,把聊天模板变成可编程对象。它支持消息渲染为 token id、把采样结果解析回结构化助手消息、按来源消息归属 token 以构建 loss mask,以及在不重新渲染历史的情况下扩展多轮 rollout。

正文 · AI 翻译

renderers:面向智能体 RL 的 Token 级模板化

今天我们开源了 renderers,这是一个独立的 Python 库,让开发者能够完全掌控用于 RL 和多轮推理的对话格式化。这一 renderer 抽象由 OpenAI 为 gpt-oss 推出的 Harmony 模板引入,并经 Thinking Machines 的 Tinker cookbook 推广,它将模型聊天模板转变为可编程的 Python 对象。它不再把聊天模板当作格式化消息的黑盒 Jinja 字符串,而是让 renderers 暴露出 RL 系统真正需要的操作:将消息渲染为 token id、将补全结果解析回结构化的 assistant 消息、将 token 归属到源消息以进行 loss masking,以及在不重新渲染模型采样历史的情况下扩展多轮 rollout。

在 Prime Intellect,我们在 Lab 产品中、以及 verifiers 和 prime-rl 中都使用了 renderers。它帮助我们减少了冗余的 tokenization,避免了此前会产生冗余训练 token 的聊天模板断裂,并使 Token-In、Token-Out 成为稳定多轮 RL 的默认原语。

本文深入探讨智能体 RL 中出现的诸多复杂模板化挑战,以及 renderers 如何让我们解决它们,包括:

  • 贪婪采样的模型补全导致的重新 tokenization 漂移
  • “黄金标准”模板带来的有损多对一工具解析
  • 空白填充中的细微不一致
  • 打包训练序列实现 3 倍冗余节省

renderers 的设计基于我们探索中得出的多项结论:

  • 对于 RL,推理服务器应当是一个仅处理 token 的简单端点。
  • 环境应当对 tokenizer 无感知,并可作为任意模型 API 的 eval 使用。
  • 人们可能对聊天模板做出的每一个假设,最终都会被打破。
  • 官方聊天模板往往是“错误”的,需要显式选择修复。

renderers 库是独立的,内置支持当今大多数流行的开放权重模型,并设计为可跨推理引擎即插即用。我们正在与领先的开源合作伙伴(包括 NVIDIA、vLLM 和 SGLang)合作,以确保 renderers 能够成为更广泛生态系统中推理与 RL 基础设施的有用参考标准。

renderers 如何工作

renderer 是面向人类的对话对象与模型实际消费和产生的 token 序列之间的结构化转换层。它有意定义了文本↔token 的边界:消息如何变成模型可用的 token id,采样的 token id 如何再次变成结构化的 assistant 消息,以及结果序列中哪些部分应当被训练。

from transformers import AutoTokenizer
from renderers import create_renderer

tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
rdr = create_renderer(tok, renderer="auto")

messages = [{"role": "user", "content": "hi"}]

prompt_ids = rdr.render_ids(messages, add_generation_prompt=True)
# Feed prompt_ids to a Token-In, Token-Out generation endpoint.
# The endpoint returns completion_ids sampled by the model.

parsed = rdr.parse_response(completion_ids)
# ParsedResponse(content=..., reasoning_content=..., tool_calls=...)

重要之处在于,renderer 工作在 token 边界,而不仅仅是字符串边界。它可以按 id 解析由特殊 token 分隔的工具调用,跨轮次精确保留采样的补全结果,并将一个 message_indices 数组附加到渲染后的 token 上,使训练器无需反复 diff prompt 即可推导出 loss mask。

对于多轮 rollout,关键操作是 bridge_to_next_turn:

next_prompt_ids = rdr.bridge_to_next_turn(
    previous_prompt_ids=prompt_ids,
    previous_completion_ids=completion_ids,
    new_messages=[{"role": "tool", "content": "..."}],
)

该桥接通过逐字保留先前的采样流,并仅追加新的环境消息以及下一个 assistant 开头,来返回下一轮的 prompt id。如果上一轮被截断,renderer 可以合成模型规范的轮次结束 token,作为非 loss 的 prompt 上下文。如果该扩展无法被证明是安全的,它会返回 None,调用方可以回退到完整渲染——仍然使用来自先前采样 id 的原始解码字节,而不是解析后的 dict。

renderers 目前包含针对 Qwen3、Qwen3.5、GLM-4.5、GLM-5、MiniMax-M2、DeepSeek-V3、Kimi K2 / K2.5、Nemotron-3 和 GPT-OSS 的手写渲染器,外加一个 DefaultRenderer 回退方案。它已在 PyPI 上发布:

uv add renderers

为什么选择 renderers

理解 renderers 的一种方式是将其视为可编程的聊天模板:在 Python 中比在 Jinja 中更易于阅读、测试和推理。这种说法有一定道理,并且它澄清了一个重要的边界。Jinja 聊天模板可以描述结构化消息如何变成文本,但它本身并不能定义 RL 系统所需的逆向及相邻操作:将采样得到的 token 解析回结构、为损失掩码归属 token,或在不重新渲染已采样历史的情况下安全地扩展先前的 token 流。

渲染器让这些能力变得显式——同样重要的是,它们让限制也变得显式。它们的职责是默认保持前缀连续性:下一轮提示应当精确地扩展先前的采样 token 流,除非系统有意改变该流,例如通过压缩。

这个不变量说起来容易,却出奇地容易被打破。困难的部分不是渲染第一个提示,而是在整个往返过程中保持 token 身份:采样 token 变成文本,文本变成解析后的结构,解析后的结构变成历史,历史变成新的提示,而该提示又变回 token。

本节的其余部分讲述的是将真相来源逐步移近模型的故事:首先从消息,然后到采样 token id,最后到渲染器作为控制二者之间转换的层。

阶段 1——消息输入,Token 输出

自然的起点是消息输入、Token 输出:将每段对话表示为消息字典列表,并在每一轮将这些消息发送到推理服务器。服务器应用自己的聊天模板,对渲染后的提示进行分词,采样一个补全,从该补全中解析工具调用和推理,并返回结构化的助手消息。客户端将该助手消息追加到历史中,并在下一轮发送更新后的历史。之后,训练器通过在记录的消息列表上运行自己的 apply_chat_template 来重建 rollout。

这是大多数推理服务器容易提供的 API。它也是 RL 的错误抽象。

可见的失败是解析器往返。假设模型为布尔参数输出了字面字节:

prev stream:  '<parameter=dry_run>\nfalse\n</parameter>'
re-rendered:  '<parameter=dry_run>\nFalse\n</parameter>'

服务器的工具调用解析器将 false 变成了 Python 布尔值。在下一次渲染时,Python 的字符串化将其变成 False。语义值相同;字节不同。下一个提示不再扩展先前的采样流。

布尔字符串化只是最容易看到的版本。每当采样流被转换为更高层表示并随后被重建时,同样的形态就会出现:被 json.dumps 规范化的空白、重新排序的字段、因字典中没有对应字段而被丢弃的空参数块、组件之间改变的特殊 token 处理,或分词器在去分词和重新分词后选择不同的 BPE 切分。

细节各有不同,但失败如出一辙:整个 rollout 看起来仍是同一段对话,然而第 N+1 轮已不再延续第 N 轮所采样的精确 token 流。Message-In, Token-Out 无法保证这一不变量,因为它的真相来源是消息叙事,而消息并非 token。一旦模型采样的字节经过解析、规范化、去 token 化并重新渲染,token 身份就已经岌岌可危。

解决办法是停止从解析后的消息重建历史,并停止对模型采样的文本重新分词。这就引出了 Token-In, Token-Out。

阶段 2 —— 通用的 Token-In, Token-Out

我们的第一步是让推理端点直接接受 prompt token id。在我们的技术栈中,这是一个自定义的 chat-completions 路由 /v1/chat/completions/tokens,以 vLLM 扩展的形式实现。该路由同时接受 messages= 和 tokens=。当提供 tokens= 时,vLLM 会直接将这些 token id 用作 prompt。

rollout 循环会记录服务器每一轮发出的 token id,并将它们复用为下一轮的前缀:

prev_turn_ids = step["tokens"]["prompt_ids"] + step["tokens"]["completion_ids"]
prompt_ids_next = prev_turn_ids + bridge_ids

prev_turn_ids 来自服务器,保留了采样流。只有 bridge_ids 是新的:传入环境消息的 token,加上下一轮助手回复的生成 prompt。

这个通用桥接通过将一段虚拟助手回合与新的环境消息一起渲染,再减去单独渲染虚拟助手的结果,来计算出这些 id:

bridge_full = tokenize([dummy_assistant] + env_messages, ...)
bridge_base = tokenize([dummy_assistant], ...)
bridge_ids  = bridge_full[len(bridge_base) - gap:]

虚拟回合为 chat 模板提供了一个挂载轮次间分隔符的对象,而无需重新渲染实际的模型输出。各模型相关的工作通过 /tokenize 留在 vLLM 一侧,因此客户端无需手写模型逻辑。

这解决了 Message-In, Token-Out 最大的问题:上一轮助手回复保持了 token 原生状态。实际采样的补全没有被解析成 dict 再重新渲染,因此布尔值规范化、空白变化、BPE 重新分词漂移,以及模板对先前助手内容的编辑,大多都消失了。

但这个桥接仍然是一种取巧。它假设“渲染虚拟助手加新环境消息,减去虚拟渲染,保留后缀”等同于真正的下一轮渲染。有时这个假设成立,有时不成立。Chat 模板可以依赖全局对话形态、角色顺序、工具状态、生成 prompt 标志,或围绕最后一个助手回合的特殊情况。虚拟回合改变了这些上下文。一旦如此,我们提取出的后缀可能看似合理,却并非正确的桥接。

有些失败是显性的:Qwen3.5 风格的模板在合成对话形态违反模板假设时,会抛出诸如 No user query found in messages. 之类的错误。另一些则是静默的:桥接在文本上看起来合理,但在 token 层面没有正确延续前缀。

通用的 Token-In, Token-Out 还存在截断问题。该桥接隐含地假设上一轮补全以模型的规范回合结束 token 结尾。在干净停止时,该 token 已经发出。而在截断时,例如触及 max_tokens 时,它就缺失了。通用桥接无法为每个模型家族判断该合成哪个结束 token、它应位于何处,或者添加它是否安全。实现通过放弃来处理这一点:

if is_truncated:
    self.logger.debug("TITO: truncated completion, falling back to MITO")
    return None

返回 None 意味着回退到仅消息的普通 chat completions。换句话说,正是我们构建 Token-In, Token-Out 所要避免的那条路径。

截断并不是唯一的逃生舱口。桥接分词可能会失败。环境响应可能具有通用验证器未曾预料到的形状。跨存储轨迹步骤的消息列表前缀匹配可能会失败。每种情况都会回到 Message-In、Token-Out,重新带来解析器漂移、BPE 漂移和模板编辑问题。

教训是:Token-In、Token-Out 是正确的原语,但桥接不能保持通用。客户端需要模型家族知识:如何合成规范的轮次结束、角色标记放在哪里、哪些 token 分隔工具和推理、模板的虚拟渲染何时无效,以及如何通过 id 而不是消息列表前缀匹配来扩展。

这就成了 renderers。

阶段 3 — renderers

renderers 通过使模型特定的桥接显式化,堵住了这些逃生舱口。

每个渲染器都用纯 Python 实现 bridge_to_next_turn,在需要对齐的地方,逐字节匹配相关模型家族的聊天框架。渲染器知道当前一轮被截断时该发出什么、角色标记应放在哪里、工具和推理部分如何分隔,以及何时扩展不安全。

当桥接无法安全应用时,它会返回 None。调用方随后可以回退到完整的 render(),但该回退操作的是从上一个 completion_ids 保留的原始解码字节,而不是可能已将模型输出规范化的解析后字典。

稳定前缀为我们带来了什么

前面的章节重点讨论了前缀为何会断裂。我们如此关注它的实际原因是,前缀连续性正是让多轮 rollout 成为一个高效训练样本的关键。

当该不变量成立时,rollout 就是一个连续的 token 流。它以初始提示开始,然后在模型补全与下一轮之前引入的新提示 token 之间交替:工具结果、用户后续输入、角色标记以及下一个助手开场白。

prompt_ids_1
+ completion_ids_1
+ continuation_ids_2 + completion_ids_2
+ ...
+ continuation_ids_N + completion_ids_N

损失掩码将助手 token 标记为可训练,其他所有内容标记为上下文。训练器可以在该序列上运行一次前向/反向传播;较早的轮次只编码一次,而梯度仍会流经每个助手轮次。

这种打包之所以有效,仅仅是因为每个下一个提示都扩展了前一个采样流。形式上,对于每一轮:

prompt_ids_{t+1} = prompt_ids_t + completion_ids_t + continuation_ids_{t+1}

如果轮次 t+1 没有逐字节扩展轮次 t,那么就不存在一个连续的序列,其前缀关系与 rollout 期间实际使用的提示相匹配。

要用其精确前缀训练后续补全,训练器必须在断点处开始一个新样本。断点之前的所有内容都成为提示上下文,训练只在该点之后的补全 token 上继续。如果之后另一个边界断裂,同样的事情会再次发生。

这保持了 token 前缀正确,但会把一次 rollout 变成多个训练样本。每个下游样本都要再次为编码前缀付出代价。一个五轮 rollout,如果每个边界都无法合并,就可能变成五个样本,携带长度为 1, 2, 3, 4, 5 轮的前缀。这大约是 15 个轮次长度的前向传播工作量,而不是 5,即大约 3x 倍的干净计算量。

Rendered Token-In Token-Out compared with Message-In Token-Out

扩展属性正是使高效打包样本有效的原因。

设计:渲染器协议与桥接

前面的章节描述了该不变量:保留采样的 token 前缀,除非系统有意重写它。渲染器协议是我们用来强制执行该不变量的接口。每个方法都对应一种过去隐式、重复或隐藏在聊天模板中的转换。

最重要的转变是桥接。通用的 TITO 试图通过渲染一个虚拟的助手回合并取后缀来推断桥接。而渲染器不进行推断。如果我们知道聊天模板,就可以直接实现桥接,并使其对该模型系列精确无误。

每个渲染器都实现一个小型协议:

class Renderer(Protocol):
    def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
    def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
    def parse_response(token_ids) -> ParsedResponse: ...
    def get_stop_token_ids() -> list[int]: ...
    def bridge_to_next_turn(
        previous_prompt_ids,
        previous_completion_ids,
        new_messages,
        *,
        tools=None,
    ) -> list[int] | None: ...

render 返回一个 RenderedTokens 对象,同时携带 token_ids 和 message_indices。message_indices 为每个 token 提供一个条目,并将每个 token 归属到其来源消息,或归属到 -1(用于角色包装器和生成提示等结构性脚手架)。

这使得损失掩码的构建成为一次遍历操作:

loss_mask = [
    role_to_mask(messages[idx]) if idx >= 0 else False
    for idx in rendered.message_indices
]

这正是让 build_training_sample 能够通过一次渲染调用组装训练样本的原因,而不是每个回合渲染一次并通过对比前缀来恢复边界。

parse_response 也适用于 token id。它会扫描特殊 token id,例如 Qwen3 上 <tool_call> 的 id,并且只解码它们之间的文本片段。用户内容中像 "<tool_call>" 这样的字面字符串会被分词为普通文本 id,而不是模型的特殊 token id,因此 id 级解析可以避免解码文本正则表达式可能引入的误报。

将协议串联起来的属性测试是往返测试:渲染一段包含带有内容、推理和工具调用的助手消息的对话;切出助手补全部分;解析它;并断言解析出的消息与原始结构化助手消息等价。

桥接

bridge_to_next_turn 是通用 TITO 变成渲染器的地方。它使扩展属性成为一等 API。

其契约是:

给定上一回合的 (prompt_ids, completion_ids) 和一组新的环境消息——工具结果、用户后续消息,绝不包括助手消息——返回下一回合提示的 id,使得结果以 previous_prompt_ids + previous_completion_ids 逐字节开始,并继续包含新消息以及下一个助手开头。如果无法证明这是安全的,则返回 None。

因为渲染器知道模型系列的聊天模板,桥接不再需要猜测后缀。它可以精确生成模板本会为新回合生成的 token,同时逐字保留采样的前缀。

每个桥接都做三件事。

首先,它锚定在上一回合的结束处。它向后遍历 previous_completion_ids 以找到模型的规范结束 token。在干净停止时,采样的补全已经包含它。在截断停止时,渲染器会合成规范结束作为提示上下文,并使用 loss_mask=False,这样训练器就不会对模型从未生成的 token 计算损失。

其次,它拒绝扩展中的助手内容。新消息可以包含工具输出或用户后续消息,但不能包含助手回合。重新渲染助手内容会用规范模板字节替换采样的字节,而这正是桥接存在所要避免的。

第三,它只以该模型系列期望的精确框架渲染新消息。Qwen 风格的渲染器知道 <|im_start|>role\n...<|im_end|>\n 框架。GLM 风格的渲染器知道它们自己的角色标记和回合结束约定。重点不是发明一个通用抽象;重点是编码少量使 token 流正确的模型特定知识。

渲染器边界止于何处

渲染器使消息/文本/token 边界显式化,但它们不会默认使堆栈的其余部分保持信息无损。只有当采样的 token 流仍然是事实来源时,它们才能保持前缀连续性。这里有两个相邻层很重要:聊天模板和 harness。

第一个边界是聊天模板本身。有些模板会有意重写历史,例如从之前的助手回合中剥离旧的 <think>...</think> 块。这曾经是避免上下文窗口溢出的合理默认做法。对于 RL 来说,这种节省发生在错误的层面:被剥离的 token 是采样轨迹的一部分,移除它们会改变训练器试图复现的历史。

一个声称与该模板严格一致的渲染器会复现这种剥离。要保留 RL 轨迹,就需要有意的偏离:渲染器保留采样的字节,并在一致性测试中标记这种偏离。这是一种受控的分歧,不是渲染器能自动推导出来的。在设计聊天模板时,值得把这些策略选择显式化,最好还能为需要它们的系统暴露保留信息的变体。

第二个边界是 harness。Harness 可以通过多种方式破坏前缀连续性:修复工具调用、重排工具定义、规范化参数、修剪或压缩旧的工具结果、用摘要替换多模态块,或者在下次模型调用前以其他方式重写更早的历史。每种改动对执行或上下文管理可能都有用,但如果它悄悄改变了采样历史,下一个提示中就包含了模型从未输出的字节。

我们在 opencode 的 AI-SDK experimental_repairToolCall 钩子中遇到了这个问题,它可以把 Bash 重写为 bash,或者合成一个 invalid 工具调用。渲染器无法事后修复,因为漂移发生在渲染之前。为了获得最佳的 RL 能力,采样历史应保持不可变,除非系统有意重写它,例如在压缩时。执行修复不应悄悄变成历史变更;如果发生了重写,应将其记录为训练器可以建模的显式事件。

结语

对于智能体 RL,推理服务器应该是一个简单的 Token-In、Token-Out 端点。

其他所有操作——聊天模板应用、解析、推理提取、工具调用处理、多轮拼接和损失掩码构建——都应该发生在你能控制且可以进行单元测试的客户端代码中。

这不仅仅是设计模式的纯粹主义。在多轮 RL 中,token 身份决定了 rollout 能否被高效地复现、打包和训练。

renderers 是让这一不变量在 Prime 技术栈中显式且可测试的层。我们已经在 verifiers 和 prime-rl 中使用它,今天我们将其作为独立包开源:

pip install renderers

源代码可在 github.com/PrimeIntellect-ai/renderers 获取。

立即使用 Prime Intellect Lab 开始训练你自己的模型。

@article{primeintellect2026renderers,
author = {Prime Intellect Team},
title = {renderers: Token-Level Templating for Agentic RL},
journal = {Prime Intellect Blog},
year = {2026},
month = {May},
note = {https://www.primeintellect.ai/blog/renderers}
}

来源:Prime Intellect Blog · primeintellect.ai