【Agentic RL / 强化学习框架】Miles 项目技术分析---(2)--- 关键技术
0x00 概要Miles 将 Slime 的研究级 RL 框架升级为Agentic-first 的企业级生产系统核心创新在于用 Session/TITO 解决多轮 tokenization 正确性用全异步staleness 解决性能用 R3True On-Policy 解决稳定性。Miles 的技术特色总结如下特色核心理念关键实现Agentic-FirstAgent 开发像写普通应用 / 多轮 RL 的正确性不是附加功能而是设计的起点Session Server TITO agentic_tool_call →miles/rollout/session/miles/rollout/generate_hub/正确性优先尽力消除训推不一致源通过多个逐步严格的层次渐进逼近R3 → 统一 FP8 → True On-Policydense 模型 → TIS/MIS性能极致推理是瓶颈在同步/异步/零拷贝/投机解码多维度榨取吞吐 / GPU永不空闲全异步train_async.py 投机解码 零拷贝 P2P RDMA 部分 rollout渐进式保证用户可以根据场景在 速度 和 正确性 之间自主选择频谱全异步 → Staleness 过滤 → TIS → R3 → True On-Policy工程纪律静默错误→显式断言 / Chat template 正确性是 Agentic RL 的基石必须验证Chat template 验证运行时prefix校验 / CI 断言tito_session_mismatch_rate 0插件化扩展模型/桥接/converter 从核心代码剥离miles_plugins/包 middleware_hubmegatron_to_hf/Multi-Agent从简单协同同模型不同 prompt到复杂异步MrlX的全频谱支持内置 Solver-Rewriter-Selector MrlX 框架主要关键技术如下#技术一句话价值1TITOtoken↔logprob 1:1 对齐, 多轮 RL 的前提2True On-Policy 合约声明式消除 off-policy bias3R3 路由重放MoE 推理↔训练路由一致性4三层解耦架构训练/缓冲/推理物理隔离可独立扩缩5Session Server 3-phase Lock有状态 Agent 多轮管理6RadixTree 前缀复用KV-cache 命中率最大化7同步权重广播 版本锁on-policy 的物理保障8Semaphore FIRST_COMPLETED精确匹配 engine 容量的并发控制9MBridge 模型抽象层9 bridge 支持异构架构10TIS Batch Abort过时样本修正 GPU 资源回收本篇会选择部分其中部分功能是miles在slime基础之上直接增强的进行分析。0x01 agentic_tool_call在 Agentic RL 中Agent 开发者需要处理的不只是如何调用工具、如何解析结果还有一连串的训练基础设施问题而agentic_tool_call 是将 Agent 业务逻辑与 RL 训练基础设施解耦的适配器。1.1 问题agentic_tool_call 要解决的问题是Agent 逻辑与训练基础设施的深度耦合。在 Miles 之前如果你想做 Agentic RL 训练——即让 Agent 进行多轮工具调用并从交互中学习——你需要自己处理以下全部问题管理多轮会话状态Session 创建、状态追踪、销毁处理增量 tokenization保证 pretokenized prefix 复用即 TITO收集每轮的 token IDs 和 log probs将多轮对话转换为训练样本正确的 loss mask——哪些 token 该训练、哪些不该处理 trailing token 边界问题stop token 去重——这个尤其容易出错处理截断和异常session 超长、Agent 执行失败合并多轮 samples 为一个训练 batch每开发一个新 Agent 就要重新实现数百行 boilerplate。这些不是写得好一点可以避免的麻烦——它们是 Agentic RL 的固有复杂度。任何一个没处理好训练就会在某个不可预测的时刻崩溃。更深层的问题是Agent 逻辑与 RL 训练基础设施高度耦合。每换一个新 Agent从数学推理换到代码生成、从单轮对话换到多轮工具调用你都要重新实现上述全部逻辑。Agent 开发者被迫成为 RL 基础设施专家——这违背了关注点分离的基本工程原则。1.2 解决方案agentic_tool_call的解决方案是一个清晰的分层架构其核心设计是关注点分离的适配器模式。即agentic_tool_call框架用一个适配器模式将两者完全解耦Agent 只需像调用 OpenAI API 一样写业务逻辑框架透明完成所有训练数据生产。────────────────── 用户的 Agent 函数 (纯业务逻辑) ────────────────── async def my_agent(base_url, prompt, request_kwargs, metadata): # 只关心: 调用 API、解析结果、执行工具 response await openai_call(base_url, messages[...], ...) tool_result await execute_tool(response.tool_calls) response2 await openai_call(base_url, messages [tool_result], ...) return {reward_info: ...} ───────────────────────────────────────────────────────────────── ↓ ↓ 完全不需要知道训练细节 ↓ ─────────────── agentic_tool_call.generate() - 框架层 ───────────── 自动处理: ① Session 创建 TITO tracing ② 调用用户 Agent 函数 ③ 收集 Session Records (token IDs log probs) ④ 转换为训练 Samples (正确的 loss mask token 对齐) ⑤ 处理 trailing token trim (stop token 去重) ⑥ 截断超长序列 ⑦ 合并/拆分多轮 samples ⑧ 异常处理 (Agent 失败 → ABORTED 状态) ─────────────────────────────────────────────────────────────────Agent 函数不需要 import miles 的任何模块。它只需要接收base_url指向 Session Server像调用标准 OpenAI API 一样发起请求。框架层在 Agent 函数的下方透明完成所有训练基础设施工作。1.3 框架自动化的主要流水线框架在generate()函数中自动完成从 Session 创建到训练 Sample 产出的主要流程如下步骤操作说明1. Session 创建OpenAIEndpointTracer.create()→ POST/sessions建立新的 TITO 追踪会话2. 调用 Agentawait custom_agent_function(base_url, prompt, ...)执行用户业务逻辑Agent 通过 base_url 与 Session Server 交互3. 收集 Recordstracer.collect_records()→ GET/sessions/{id} DELETE获取完整的多轮 token/logprob 记录并清理4. TITO 对齐compute_samples_from_openai_records()trailing token trim确保 token 序列精确对齐5. 转 Sample每轮转为一个Sample合并 metadata构建正确的 loss mask 和 token 边界6. 异常处理try/except→ABORTEDstatus任何环节失败都优雅降级不阻塞训练每一步都封装了复杂的内部逻辑。Agent 开发者看到的是一个generate(agent_function, prompt)的简单接口——传入业务函数和 prompt拿到训练就绪的 Sample 列表。1.4 深入三个关键设计1.4.1 Trailing Token Trim多轮对话中最容易出错的边界问题我们思考下这样一个场景。第 N 轮模型生成 assistant 回复最后一个 token 是|im_end|stop token。第 N1 轮Chat Template 渲染 tool 消息时也会在边界处追加|im_end|——这是模板自动添加的不是模型生成的。问题如果不去重同一个|im_end|token 会被计算两次——一次作为第 N 轮 assistant 输出的末尾一次作为第 N1 轮 tool 消息的边界 token。这会导致 loss 计算错误把不属于任何一轮的 token 纳入训练和 token 序列膨胀每次拼接都多一个 token。Miles 的解决方案是一个贪婪匹配 裁剪算法accumulated_token_ids [P1, A1, T1, A2, T2, ...] (TITO 累积的完整序列) output_ids A1_model_output (SGLang 实际输出的 token) Step 1: cursor len(prompt_ids) → 定位到当前轮 assistant 开始位置 Step 2: 贪婪匹配 output_ids[j] accumulated[cursor j] Step 3: 不匹配的 trailing token trim_count → 从 sample 中裁剪 Step 4: cursor matched → 指向下一轮起始 Step 5: 验证 cursor len(accumulated) → 整个序列被完整消费核心思路是用 TITO 累积的完整序列作为ground truth将 SGLang 实际输出的 token 序列与之对齐裁剪掉被下一轮模板消费掉的尾部 token。第五步的验证是关键——如果 cursor 不等于 accumulated 长度说明对齐失败框架会抛出异常而非静默产生错误数据。1.4.2 两种 Sample 模式合并 vs 独立不同的 RL 算法对训练数据的粒度有不同需求。agentic_tool_call提供两种模式模式说明适用场景merge_samples默认多轮合并为一个 Sample标准 GRPO/PPO——整个 trajectory 一个 rewardgenerate_multi_samples每轮独立 Sample需要 per-turn reward 的场景如 PRM 逐轮打分选择合并模式时所有轮次的 token 拼接为一个完整序列loss mask 正确标记了每一段 assistant 回复的位置。选择独立模式时每一轮产出独立的 Sample可以分别打分、分别计算 advantage。框架处理所有拼接和对齐细节Agent 开发者只需在配置中切换模式。1.4.3 五层异常处理不让任何一个 Agent 失败阻塞训练Agentic RL 训练中Agent 执行失败是常态而非异常——工具调用超时、模型生成格式错误、网络抖动任何一个都可能导致单次 Agent 执行失败。如果每次失败都让训练崩溃Agentic RL 根本无法实用化。agentic_tool_call实现了五层递进的异常处理层异常类型处理方式Agent 异常用户函数内任意 Exceptionsample.status ABORTED返回空 records空 recordsAgent 未调用任何模型返回单个 ABORTED sample超长序列tokens 超过max_seq_lentruncate_samples_by_total_tokens()全部截断prompt 本身就超过max_seq_len返回 ABORTEDSession 收集超时asyncio.TimeoutError返回空 records 清理 session核心原则任何一层失败都优雅降级为 ABORTED 状态不抛异常到训练循环。ABORTED 样本在后续的 data filter 中被自动丢弃或 loss_mask 置零——训练继续不受单次 Agent 失败影响。这使得 Agentic RL 训练可以像普通 RL 训练一样稳定运行即使 Agent 的失败率在高难任务上可能达到 30-50%。1.5 有框架 vs 无框架将上文所有自动化的维度汇总在一起有无agentic_tool_call的差异是数量级的维度无 agentic_tool_call有 agentic_tool_callAgent 开发需了解 Miles 内部 token 格式像写普通 AgentOpenAI API 风格Session 管理手动创建/销毁自动Token 对齐手动实现 TITO 逻辑自动含 trailing token trimLoss Mask手动计算边界自动异常处理自行处理失败训练崩溃自动降级为 ABORTED新 Agent 开发成本数百行 boilerplate只写业务逻辑1.6 总结agentic_tool_call的本质是一个适配器模式——它将Agent 多轮交互业务关注点与RL 训练数据生产基础设施关注点完全解耦。这条解耦线画在了generate()函数上。线以上是 Agent 开发者的世界——OpenAI API、工具调用、业务逻辑。线以下是 RL 基础设施的世界——Session Server、TITO、token 对齐、loss mask、异常降级。Agent 开发者不需要知道线以下的存在框架也不需要知道 Agent 在做什么业务。这正是好的抽象应该达到的效果。0x02 TITOTITO (Token-In, Token-Out) 是多轮 Agent RL 的 Tokenization 一致性基础设施在多轮 Agent RL 中每轮对话后的重新 tokenize是一个隐形的训练杀手——它让前缀漂移、log prob 发散、loss mask 错位最终导致梯度崩溃。本节从 Chat Template 的loop.last问题出发拆解 TITO 增量 tokenization 的完整设计说明 Miles 如何通过三道防线根治这个问题。2.1 问题多轮 Agent RL 中的 Tokenization 漂移在 Agentic RL 中, 每轮对话需要精确的 token 级 log prob 用于 policy gradient 计算。传统方法对完整对话重新 tokenize 会因 BPE 合并边界变化导致 token 序列不一致, 进而破坏 token↔logprob 的 1:1 对应关系。没有 TITO, 多轮 RL 的 reward 归因完全失效。这是 Miles 区别于所有竞品的基础前提。2.1.1 一个具体的场景假设一个 Agent 正在执行多轮工具调用。第 1 轮它收到用户请求后生成了一个 tool call第 2 轮工具返回结果Agent 需要继续生成。在标准做法中每轮对话结束时框架会把完整的消息历史system user assistant tool ...作为一个整体调用tokenizer.apply_chat_template()重新渲染并 tokenize。# 第 1 轮: 3 条消息 messages [system, user, assistant] tokens_A tokenizer.apply_chat_template(messages) # 1000 个 token # 第 2 轮: 4 条消息 (加了 tool) messages [system, user, assistant, tool] tokens_B tokenizer.apply_chat_template(messages) # 重新 tokenize 全部问题tokens_B的前 1000 个 token不等于tokens_A。为什么会这样答案藏在 HuggingFace 的 Chat Template 机制里。2.1.2 根因Chat Template 中的loop.lastHuggingFace 模型的 chat template 是一个 Jinja2 模板它将 messages 列表渲染为模型能理解的文本带|im_start|/|im_end|等特殊 token。在这个渲染过程中模板经常使用loop.last来判断当前消息是不是最后一条然后决定是否追加某个结束符。以 Qwen3 的原始模板为例tool 消息的渲染逻辑大致如下{%- elif message.role tool %} {%- if loop.first or (messages[loop.index0 - 1].role ! tool) %} {{- |im_start|user }} {%- endif %} {{- \ntool_response\n content \n/tool_response }} {%- if loop.last or (messages[loop.index0 1].role ! tool) %} {{- |im_end|\n }} ← 只在最后一条时加结束符 {%- endif %} {%- endfor %}让我们追踪两次渲染之间的差异第 2 轮结束时tool 是最后一条消息: messages [..., tool_msg] ← loop.last True 渲染结果: ...|im_end|\n ← 加了结束符 第 3 轮开始时tool 后面又加了 assistant: messages [..., tool_msg, assistant_msg] ← loop.last False 了! 渲染结果: ... ← 没有 |im_end|\n 了!前缀不再一致。第 2 轮的 token 序列包含|im_end|token第 3 轮却没有——TITO 的 pretokenized prefix 复用完全失效。2.1.3 后果从性能浪费到训练崩溃这不仅是多算了几次 tokenization的问题。我们逐层来看第一层性能浪费。不能复用 prefix 意味着每轮都要对整个历史重新 tokenize。10 轮对话 × 32K 上下文计算开销从 O(N) 变成 O(N²)。第二层Log Probability 发散。第 2 轮推理时位置 100 是|im_end|token模型给它算出了log_prob -0.01。第 3 轮重算时位置 100 的 token 变了——|im_end|不存在了被下一个 token 替代。同一位置、不同 log prob → importance ratio ≠ 1.0 → 虚假的策略梯度。第三层Loss Mask 错位。训练时需要精确标记哪些 token 是模型生成的需要计算 loss、哪些是环境返回的不需要。一旦 token 序列漂移loss mask 的边界就错位了——可能把 tool response 的 token 也纳入 loss 计算让模型被迫预测工具输出。训练信号被污染。第四层梯度崩溃。每轮的微小偏移 × 多轮对话 × 大 batch × 数千训练步 → 策略梯度持续偏差 → reward hacking 或 loss 不降反升 → 最终训练 collapse。2.2 TITO增量 Tokenization 的原理2.2.1 核心思想TITOToken-IncrementalTokenization for pretokenized prefix reuse的核心思路只有一句话只 tokenize 新增部分复用已有前缀的 token IDs, 保证多轮对话 token 前缀严格一致。多轮 Agent 交互中token 只有两个来源环境/用户输入tool/user/system 消息需要 TITO 增量 tokenize模型输出assistant 消息SGLang 生成时直接产出 token IDs 和 logprobs——它们在生成瞬间就是确定的不应、也不能重新 tokenize因此TITO 的做法是只 tokenize 非 assistant 消息(tool/user/system)Assistant tokens 直接从 SGLang engine 获取, 已天然绑定 logprobs保证整个对话的 token 前缀在多轮追加时位级一致具体如下第 1 轮: [system] [user] [assistant] ├─ 完整 render tokenize → token_ids_1 (checkpoint) 第 2 轮: [system] [user] [assistant] [tool_1] ├─ 复用 checkpoint: token_ids_1 └─ 只 tokenize [tool_1] → incremental → token_ids_1 incremental 第 3 轮: [system] [user] [assistant] [tool_1] [assistant] [tool_2] ├─ 复用 checkpoint: token_ids_2 └─ 只 tokenize [tool_2] → incremental → token_ids_2 incremental关键保证每轮只向前追加永远不重新 tokenize 已有消息。这就是append-only 不变量。2.3 三道防线Miles 的工程化方案理解了问题本质后我们来看看 Miles 如何系统性地解决它。2.3.1 防线一修复模板根治修复的核心思路是将判断条件从loop.last当前是不是最后一条替换为基于下一条消息角色的判断{# 修复后 - 用下一条消息判断代替 loop.last #} {%- if loop.last or (messages[loop.index0 1].role ! tool) %} {{- |im_end|\n }} {%- endif %}这确保了当 tool 是最后一条时依然追加结束符因为下一轮 assistant 的起始标记会自然衔接。关键原则是append-only 不变量渲染messages[0:N]的结果必须是渲染messages[0:N1]结果的严格前缀。模型特定适配不同模型的 chat template 有不同的边界行为。TITO 通过子类化 固定模板解决Miles 为每个有问题的模型提供了*_fixed.jinja模板模型TITO 子类边界处理Qwen3Qwen3TITOTokenizer|im_end|后补\nQwen3.5/3.6Qwen35TITOTokenizer同 Qwen3使用不同的固定 jinjaGLM 4.7GLM47TITOTokenizer|user|和|observation|歧义边界 tokenKimi K2.5/K2.6Kimi25TITOTokenizer/Kimi26TITOTokenizer|im_end|无 trailing newlineMiniMax M2.5/M2.7MinimaxM25TITOTokenizer/MinimaxM27TITOTokenizer[e~[\n边界处理Nemotron 3Nemotron3TITOTokenizer同 Qwen3 模式工厂函数get_tito_tokenizer()根据--tito-model参数自动选择对应子类。以 Qwen3 为例模型实际生成时停在|im_end|而不生成紧随其后的\n但 chat template 渲染时又包含了这个\n。TITO 的merge_tokens方法需要补回这个缺失的换行符def merge_tokens(self, ...): incremental self.tokenize_additional_non_assistant(...) prefix list(pretokenized_token_ids) if prefix and prefix[-1] self._im_end_id: prefix.append(self._newline_id) # 补回缺失的换行符 return prefix incremental实现要点代码实现上tokenize_additional_non_assistant()方法将新增消息按 segment 分组连续 tool 为一组user/system 单独一组每组用最小合成上下文渲染后 tokenize最后追加 generation prompt。miles/utils/chat_template_utils/ ├── tito_tokenizer.py # 增量 tokenizer 核心 ├── __init__.py # 对外导出 TITO 接口 └── (由 session/linear_trajectory.py 调用)传统做法 vs TITO传统做法每轮对全部 N 条消息重新 tokenize → O(N²) 且前缀不稳定 TITO 做法只 tokenize 第 N 轮新增消息 → O(N) 且前缀 100% 稳定