feat(renderer): add Kimi K3 chat renderer with thinking/disable-thinking modes - #555
feat(renderer): add Kimi K3 chat renderer with thinking/disable-thinking modes#555yoyoyocmu wants to merge 3 commits into
Conversation
…ing modes Kimi K3 uses a structured <|open|>/<|sep|>/<|close|>/<|end_of_msg|> chat format unrelated to the K2.x <|im_*|>/<think> families, so no existing renderer applies (kimi_k27_code assumes <think> is a single token, which K3 tokenizes into three). This adds a KimiK3Renderer (thinking, default) and a KimiK3DisableThinkingRenderer, registered as "kimi_k3" and "kimi_k3_disable_thinking". Rendering is validated byte-for-byte against the tokenizer's own apply_chat_template. Thinking is uniformly stripped from history, so the default concatenating build_supervised_example / build_generation_prompt are exact and multi-turn ALL_ASSISTANT_MESSAGES SFT is well defined. Tools and vision are intentionally out of scope (documented).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ac5a17d. Configure here.
| for opener in (f"{_OPEN}think{_SEP}", f"{_OPEN}response{_SEP}"): | ||
| if opener in text: | ||
| text = text.split(opener, 1)[1] | ||
| return Message(role="assistant", content=text.strip()), termination |
There was a problem hiding this comment.
Think-only completions leak reasoning
Medium Severity
For the default kimi_k3 renderer, parse_response only extracts the response channel when the decoded completion contains <|open|>response<|sep|>. Sampling starts inside the prefilled think section, so truncations or stop-before-response completions still in think are returned whole as assistant content, exposing chain-of-thought to RL grading and agent parsing.
Reviewed by Cursor Bugbot for commit ac5a17d. Configure here.
…hought Sampling prefills the assistant generation suffix's section opener (think for the default renderer, response for the disable-thinking variant), which is not present in the returned tokens. The previous parse_response returned the raw decoded text as content whenever it did not find a response-channel opener, so completions that stopped while still in the think section (truncation or stop-before-response) leaked the model's chain-of-thought into assistant content — exposing it to RL grading and agent parsing (flagged by Bugbot). Restore the prefilled section opener in _normalize_response_tokens so parsing is self-describing, then split the think channel from the response channel: reasoning is routed to reasoning_content and only the response channel becomes content (empty when no response was emitted yet). Adds regression tests for full-thinking, think-only/truncated, post-think-close, response-only, and the disable-thinking variant.
…llback Add two tests to reach 100% line coverage of kimi_k3.py: a non-assistant-role generation suffix (exercises the else branch of _get_generation_suffix) and an _encode fallback for tokenizers whose encode() rejects allowed_special (HF fast/slow), which the tiktoken-based K3 tokenizer never triggers.


Summary
Adds a first-class renderer for Kimi K3's chat format. K3 uses a structured, self-delimiting format (
<|open|>/<|sep|>/<|close|>/<|end_of_msg|>) that is unrelated to the K2.x<|im_*|>/<think>families — so no existing renderer works. In particularkimi_k27_codecrashes on K3 because it assumes<think>is a single special token, whereas K3 tokenizes<think>into['<','think','>']and expresses reasoning as a structuredthinksection instead.Registers two names:
kimi_k3— thinking enabled (HF default); generation suffix opens athinksection.kimi_k3_disable_thinking— HFthinking=False; generation suffix opens aresponsesection directly.Format (validated byte-for-byte vs the tokenizer's
apply_chat_template)Thinking is uniformly stripped from history (K3 ignores
reasoning_contenton historical assistant turns), so every message renders identically regardless of position and each conversation prefix is a strict token prefix of the next. The default concatenatingbuild_supervised_example/build_generation_promptare therefore exact,has_extension_property=True, and multi-turnALL_ASSISTANT_MESSAGESSFT is well defined.Structural markers are encoded with
allowed_special="all"so they map to their single special-token ids (matchingapply_chat_template).Type of change
Testing
training/tests/unit/test_kimi_k3_renderer.py— 24 tests, all passing (run in a K3 trainer pod with the deployed tokenizer):apply_chat_template(ID-for-ID): generation prompt (user-only, system+user, single/multi-turn, with-reasoning, unicode) and supervised example (single/multi-turn, reasoning-stripped, empty assistant, unicode).LAST_ASSISTANT_MESSAGEvsALL_ASSISTANT_MESSAGES; response closers trained; headers / user / system never trained.parse_responseroundtrip + unterminated, registration/factory wiring, stop sequences.The tokenizer is not on the HF Hub, so the suite loads a local mirror (
/shared/text-models/kimi-k3, override viaKIMI_K3_TOKENIZER) and skips cleanly when absent — same pattern as the DeepSeek-V4 suite. The mirrored chat-format files (tokenization_kimi.py,encoding_k3.py,tokenizer_config.json,tiktoken.model) are MD5-identical to the upstream model bucket.Scope
Intentionally out of scope (documented in the module docstring): tool declarations /
tool_calls/role="tool"results (the nested<|open|>tools...call...argument...encoding) and multimodal/image content. The text/coding SFT path does not use them and the K3 tool/vision encodings are substantially more involved. Follow-up if needed.Code overview
flowchart TD A["messages[]"] --> B{"role?"} B -->|assistant| C["header: <|open|>message role=assistant<|sep|><|open|>response<|sep|>\noutput: content + closers + <|end_of_msg|>"] B -->|system/user| D["header: <|open|>message role=X<|sep|>\noutput: content + <|close|>message<|sep|><|end_of_msg|>"] C --> E["build_supervised_example (base):\nconcat header+output per msg,\nweight output by train_on_what"] D --> E E --> F["tokens (== apply_chat_template)\n+ per-token weights (response spans only)"] B -.gen prompt.-> G["_get_generation_suffix:\nthink (kimi_k3) | response (disable)"]Checklist
Made with Cursor