Prhub

#2302 fix(inkling): parse raw completions in the session server

原始 PR 作者 Michaelsqj 合并时间 2026-09-03 07:53 文件变更 10 提交数 10 评论 10 代码增减 +754 / -26

执行摘要

修复 Inkling 模型原始完成解析,将 token 级别解析权归还会话服务器,消除 TITO 不匹配。

这是一个重要的 bug 修复和架构改进,值得精读。其核心价值在于:1)解决了影响训练数据准确性的实际问题;2)展示了通过钩子机制将模型特定逻辑解耦的优雅设计;3)提供了处理复杂模型协议的完整实现范例(解析、状态维护、模板渲染、测试)。建议重点关注 InklingResponseParser 的状态机实现、TITOTokenizer 钩子的设计以及模板中 content_blocks 的处理逻辑。guapisolo 在评论中提出的架构讨论也极具参考价值。

讨论亮点

核心讨论围绕着实现路径的安全性和架构权衡展开:

  • guapisolo (Reviewer) 指出原始路径 raw Inkling token IDs -> model-specific Miles parser -> OpenAI projection -> exact raw-ID replay 可能不安全,是一个变通方案。他引用了关于其他模型(如 Qwen, GLM)边界问题的讨论,并质疑不应将聊天模板行为硬编码为合并令牌。他认为应该将解析行为解耦,并建议将 PR#2282 的聊天模板修改合并进来,以形成更清晰的 TITO 数据流。
  • guapisolo 进一步提出,更好的架构是:输入 SGLang 返回的 token,输出解析后的消息(包含内容块);当新消息到来时,仍对新 token 应用聊天模板并进行简单的令牌合并,同时整合 PR#2282 的模板修改。
  • 决策与结论:最终讨论(在 Issue 评论中)以 “Modified. Need code author prod run, thanks” 结束,表明设计方向得到了调整(可能采纳了解耦建议),并需要作者进行生产环境验证。PR 最终被 guapisolo 批准,并多次重新运行端到端测试(test_inkling_small_4layer_ci.py 和 test_inkling.py)以确保正确性。这表明最终的实现(将解析器输出通过 content_blocks 和模板渲染来重放,而非直接存储原始 ID)可能是在讨论中演化出的折衷方案,它保持了 Miles 内部处理的解耦,同时避免了跨仓库依赖。

实现拆解

本实现遵循“解耦解析,统一钩子”的设计,将变更分为以下关键步骤:

  1. 引入模型族响应钩子:在基类 TITOTokenizer (miles/utils/chat_template_utils/tito_tokenizer.py) 中新增 postprocess_completion 和 preserve_server_message_state 两个默认空操作方法。这为所有模型族提供了一个标准接口,用于在会话服务器提交前对上游补全进行自定义后处理,并维护服务器端状态。此设计确保非 Inkling 模型行为不变。
  2. 实现 Inkling 专用解析器:新增 miles/utils/chat_template_utils/inkling_parser.py 文件,其中包含无状态的 InklingResponseParser 类。该解析器接收完成 token ID 列表,通过线性状态机扫描控制 token,将其分解为有序的思考、文本和原生 JSON 调用块,并投影为标准 OpenAI 的 reasoning_content、content 和 tool_calls 字段。解析结果封装在 ParsedAssistantCompletion 数据类中,同时提供客户端消息、存储消息和解析错误信息。
  3. 在 Inkling 分词器中集成解析器:在 InklingTITOTokenizer 子类 (tito_tokenizer.py) 中重写上述钩子。postprocess_completion 方法在首次调用时惰性创建 InklingResponseParser 实例,并使用其解析完成 ID,更新 choice 字典(如修正 finish_reason)并返回包含 content_blocks 的存储消息。preserve_server_message_state 方法则在客户端重放扁平化消息后,根据存储的消息恢复服务器端的 content_blocks 状态,确保后续模板渲染的精确性。
  4. 更新 Inkling 固定模板:修改 miles/utils/chat_template_utils/templates/inkling_fixed.jinja,使其优先检查消息是否存在 content_blocks 属性。如果存在,则按块类型(thinking/text/tool_call)和原始数据(recipient/raw_json)进行精确渲染,而非依赖扁平化的 content、reasoning_content 和 tool_calls。这保证了从服务器存储状态到 token 流的往返一致性。
  5. 集成到会话核心逻辑:在 miles/rollout/session/core.py (v1) 和 miles/rollout/session/v2/core.py (v2) 的 chat_completions 函数中,在提取完成结果后、提交状态前,调用 tito_tokenizer.postprocess_completion 钩子。同时,在 v1 路径中,调用 tito_tokenizer.preserve_server_message_state 来修正传递给状态更新函数的消息。这确保了两个会话服务器版本的行为一致。
  6. 全面测试覆盖:新增和修改了大量测试文件。tests/fast/utils/chat_template_utils/test_inkling_response.py 覆盖了解析器的各种边界情况(有序块、标记文本、控制令牌、格式错误)。test_tito_tokenizer.py 增加了对默认钩子和 Inkling 模板有序块渲染的测试。test_sessions.py 和 test_sessions_v2.py 则通过 monkeypatch 验证了钩子在会话核心流程中被正确调用一次,并保证了状态维护的顺序。还调整了端到端奇偶性测试以对齐 rollout ID。
文件 模块 状态 重要度
miles/utils/chat_template_utils/inkling_parser.py 解析器 added 9.21
miles/utils/chat_template_utils/tito_tokenizer.py 分词器 modified 7.78
tests/fast/utils/chat_template_utils/test_inkling_response.py 解析器测试 added 8.12
miles/rollout/session/core.py 会话核心 modified 6.05

关键符号

InklingResponseParser.__init__ InklingResponseParser._single_token_id InklingResponseParser.parse TITOTokenizer.postprocess_completion TITOTokenizer.preserve_server_message_state InklingTITOTokenizer.postprocess_completion InklingTITOTokenizer.preserve_server_message_state

关键源码片段

miles/utils/chat_template_utils/inkling_parser.py core-logic

核心变更:新增无状态、基于 token ID 的 Inkling 响应解析器,是整个修复方案的核心逻辑所在。

# from miles/utils/chat_template_utils/inkling_parser.py
class InklingResponseParser:
    """Parse Inkling control-token IDs without interpreting marker-like text."""
​
    # 定义 Inkling 协议中所有特殊控制 token 的文本形式
    _TOKEN_TEXT = {
        "message_model": "<|message_model|>",
        "content_thinking": "<|content_thinking|>",
        "content_text": "<|content_text|>",
        "content_invoke_tool_json": "<|content_invoke_tool_json|>",
        "content_invoke_tool_text": "<|content_invoke_tool_text|>",
        "end_message": "<|end_message|>",
        "end_sampling": "<|content_model_end_sampling|>",
    }
​
    def __init__(self, tokenizer: Any):
        self.tokenizer = tokenizer
        # 预计算每个控制 token 文本对应的 token ID
        self.ids = {name: self._single_token_id(literal) for name, literal in self._TOKEN_TEXT.items()}
        # 所有控制 token ID 和分词器原生特殊 ID 都视为“边界”,用于区分控制指令和模型生成的普通文本
        self._control_ids = frozenset({*self.ids.values(), *getattr(tokenizer, "all_special_ids", [])})
​
    def parse(
        self,
        completion_token_ids: list[int],
        *,
        finish_reason: str | None,
    ) -> ParsedAssistantCompletion:
        # 状态机:state in {"header", "payload", "between"}
        # "header": 正在读取消息头(如工具调用的接收者)
        # "payload": 正在读取内容块负载(思考、文本、JSON)
        # "between": 两个消息块之间,等待下一个 <|message_model|>
        state = "header"
        # 状态机输出容器
        blocks: list[dict[str, Any]] = [] # 有序的原始内容块
        visible_parts: list[str] = [] # 用于 OpenAI `content` 字段的文本部分
        reasoning_parts: list[str] = [] # 用于 OpenAI `reasoning_content` 字段的文本部分
        tool_calls: list[dict[str, Any]] = [] # 用于 OpenAI `tool_calls` 字段
        error: str | None = None
        ended = False
​
        # ... ( 状态机核心循环逻辑 ) ...
​
        # 构建客户端消息(OpenAI 格式)
        client_message: dict[str, Any] = {
            "role": "assistant",
            "content": "".join(visible_parts),
        }
        if reasoning_parts:
            client_message["reasoning_content"] = "".join(reasoning_parts)
        if tool_calls and parse_error is None:
            client_message["tool_calls"] = tool_calls
        # 构建存储消息(Miles 内部使用,包含用于精确重放的有序块)
        stored_message = {
            **client_message,
            "content_blocks": blocks,
        }
        return ParsedAssistantCompletion(
            client_message=client_message,
            stored_message=stored_message,
            parser_name="inkling",
            parse_error=parse_error,
        )
miles/utils/chat_template_utils/tito_tokenizer.py core-logic

关键集成点:在基类中定义钩子,并在 Inkling 子类中实现,将解析器集成到 tokenization 流程中。

# from miles/utils/chat_template_utils/tito_tokenizer.py
# --- 基类 TITOTokenizer 新增钩子 ---
class TITOTokenizer:
    # ... 现有代码 ...
​
    def postprocess_completion(
        self,
        *,
        choice: dict[str, Any],
        assistant_message: dict[str, Any],
        completion_token_ids: list[int],
    ) -> dict[str, Any]:
        """默认空操作:直接返回上游的 assistant_message。"""
        return assistant_message
​
    def preserve_server_message_state(
        self,
        stored_messages: list[dict[str, Any]],
        request_messages: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        """默认空操作:直接返回 request_messages 的浅拷贝。"""
        return list(request_messages)
​
# --- InklingTITOTokenizer 重写钩子 ---
class InklingTITOTokenizer(TITOTokenizer):
    # ... __init__ 保持不变 ...
​
    def postprocess_completion(
        self,
        *,
        choice: dict[str, Any],
        assistant_message: dict[str, Any],
        completion_token_ids: list[int],
    ) -> dict[str, Any]:
        # 惰性初始化 Inkling 专用解析器
        if self._response_parser is None:
            self._response_parser = InklingResponseParser(self.tokenizer)
        parsed = self._response_parser.parse(
            completion_token_ids,
            finish_reason=choice.get("finish_reason"),
        )
        # 用解析后的客户端消息替换上游消息
        choice["message"] = parsed.client_message
        # 在元信息中标记使用了 Miles 解析器及其可能的错误
        meta_info = choice.setdefault("meta_info", {})
        meta_info["miles_response_parser"] = parsed.parser_name
        if parsed.parse_error is not None:
            meta_info["miles_response_parse_error"] = parsed.parse_error
        # 如果解析出工具调用且上游认为生成已停止,则修正 finish_reason
        elif parsed.client_message.get("tool_calls") and choice.get("finish_reason") == "stop":
            choice["finish_reason"] = "tool_calls"
        # 返回包含有序块的存储消息,供后续状态更新和模板渲染使用
        return parsed.stored_message
​
    def preserve_server_message_state(
        self,
        stored_messages: list[dict[str, Any]],
        request_messages: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        preserved = list(request_messages)
        for index, request_message in enumerate(preserved):
            if index >= len(stored_messages):
                break
            stored_message = stored_messages[index]
            # 仅对角色为 assistant 且内容匹配的消息进行状态恢复
            if stored_message.get("role") != "assistant" or not strict_message_matches(
                stored_message, request_message
            ):
                continue
            # 将服务器端存储的 content_blocks 附加到客户端重放的消息上
            if "content_blocks" in stored_message:
                preserved[index] = {**request_message, "content_blocks": stored_message["content_blocks"]}
        return preserved

评论区精华

架构路径安全性与解耦讨论 设计

guapisolo 指出原始 PR body 中描述的直接重放原始 token ID 路径可能不安全,是变通方案,并质疑硬编码模板行为。他建议将解析行为与消息合并逻辑解耦,并考虑整合之前 PR#2282 的模板修改,以获得更清晰的 TITO 数据流。

结论:讨论指向一个更清晰的架构:解耦解析(输入 token,输出带内容块的消息)和合并(应用聊天模板并简单合并令牌)。最终实现通过 content_blocks 属性和模板渲染间接达到了类似效果,保持了 Miles 内部处理的解耦。 · 已解决

风险与影响

  1. 兼容性风险:变更主要限于 InklingTITOTokenizer 子类和默认钩子,理论上不影响其他模型族。但需确认 tito_tokenizer.py 中导入 InklingResponseParser 是否会为非 Inkling 场景引入不必要的依赖。
  2. 正确性风险:InklingResponseParser.parse 是一个复杂的状态机,处理各种 token 序列边界和错误条件。尽管测试覆盖全面,但真实模型输出的 edge case(如极长、格式严重错误的序列)仍可能导致意外行为。错误处理策略(仅设置 parse_error 并返回)依赖上游重试逻辑,但未指定具体策略。
  3. 性能风险:解析器是线性复杂度,且惰性初始化,性能影响应可控。但 preserve_server_message_state 在每次请求时都会遍历消息列表,对于长对话可能有轻微开销。
  4. 状态一致性风险:postprocess_completion 修改了传入的 choice 字典(如 finish_reason),这要求上游调用方必须正确处理这些就地修改,否则可能导致状态不一致。
  5. 模板渲染风险:inkling_fixed.jinja 的更新引入了新的分支逻辑(ordered_blocks),需要确保所有生成路径都能正确填充 content_blocks,否则会回退到旧逻辑,可能导致不一致。
  1. 用户/代理影响:直接影响使用 Inkling 模型进行 agentic rollout 的用户。修复了之前因解析错误导致的工具调用失败、推理内容丢失或文本被意外删除的问题,提升了代理任务的可靠性和训练数据的质量。
  2. 系统影响:在会话服务器的请求处理路径中引入了一个新的处理阶段(postprocess_completion)。虽然默认为空操作,但为未来其他模型族的自定义响应处理提供了扩展点。变更了 TITOTokenizer 基类的接口,增加了向后兼容的钩子方法。
  3. 团队影响:为处理具有复杂响应格式的模型提供了一种清晰的模式:在 Miles 的会话服务器层进行 token 级解析,并通过模板维护状态。这减少了对 SGLang 等外部组件解析逻辑的依赖,增强了 Miles 的自主控制能力。
核心路径变更 复杂状态机引入 跨仓库协议边界 状态就地修改

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论