Skip to content

Studio: fix the manual response-template markers that never match their rendered templates - #7062

Merged
danielhanchen merged 6 commits into
mainfrom
studio-template-marker-fix
Jul 12, 2026
Merged

danielhanchen merged 6 commits into
mainfrom
studio-template-marker-fix

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Jul 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Complements #7054 (auto-first completion masking): that PR makes train_on_completions auto-detect the instruction/response markers from the chat template at runtime, with Studio's manual TEMPLATE_TO_RESPONSES_MAPPER table as the fallback. This PR makes that fallback table correct. Six template families shipped manual markers that do not match what their chat templates actually render: five masked every assistant token (the run then died on the all-labels-masked safety net), and qwen3-thinking on Thinking-2507 checkpoints matched at most the final turn, silently skipping every earlier assistant turn.

Marker changes (old -> new)

Template Instruction Response Why the old one failed
mistral '[INST] ' -> '[INST]' ' [/INST]' -> '[/INST]' [INST]/[/INST] are single special tokens in v0.3; the surrounding spaces fold into neighbouring text tokens
llama (Llama-2) '[INST] ' -> '[INST]' ' [/INST]' -> '[/INST]' same space-folding, SentencePiece pieces
starling 'GPT4 Correct User: ' -> 'GPT4 Correct User:' 'GPT4 Correct Assistant: ' -> 'GPT4 Correct Assistant:' trailing space folds into the next content token (▁Hello)
glm `'[gMASK]< user >'->'<
qwen3-thinking unchanged `'< im_start
zephyr `'< user >\n'->'\n<

All markers were derived empirically from what each family's representative tokenizer renders for a two-turn conversation, and cross-checked against unsloth_zoo.get_chat_template_parts where it succeeds (the zephyr auto-detect fix is unslothai/unsloth-zoo#899).

Validation

Token-level, on each representative tokenizer (unsloth/mistral-7b-instruct-v0.3, unsloth/llama-2-7b-chat, unsloth/Starling-LM-7B-beta, unsloth/GLM-4.7-Flash, unsloth/Qwen3-4B-Thinking-2507, Qwen/QwQ-32B, unsloth/zephyr-sft), two-turn fixture plus system message, released unsloth_zoo:

  • user and system content fully masked
  • EVERY assistant turn trained (not just the last)
  • the final EOS label is never -100 (no infinite-generation regression)
  • mistral, llama, starling and glm now produce labels identical to auto-detection; qwen3-thinking differs from auto only in one turn-separator newline token (consistent with the other qwen entries)
  • all 25 unchanged table entries produce byte-identical labels to main (checked per representative tokenizer)

Consumers audited: core/training/trainer.py and core/training/worker.py only pass the strings through to train_on_responses_only; no other code depends on the old literals (the [INST] in core/inference/inference.py is an independent fallback formatter).

Tests

studio/backend/tests/test_response_template_markers.py: pins the fixed and key unchanged marker literals (dependency-free, runs everywhere) and runs the token-level masking checks above, skipping when tokenizers or unsloth_zoo are unavailable offline.

19 passed

danielhanchen and others added 2 commits July 10, 2026 13:24
Six template families in TEMPLATE_TO_RESPONSES_MAPPER shipped markers that
never match what their chat templates actually render, so the manual
train_on_completions path masked every assistant token and the run died on
the all-labels-masked safety net:

- mistral, llama: '[INST] ' / ' [/INST]' - the surrounding spaces fold into
  the neighbouring tokens ('[INST]'/'[/INST]' are single special tokens in
  Mistral v0.3, SentencePiece pieces in Llama-2), so the padded strings
  never match. Now '[INST]' / '[/INST]'.
- starling: trailing space after 'GPT4 Correct Assistant:' folds into the
  next content token. Now no trailing space.
- glm: '[gMASK]<sop>' renders once at text start, never before later user
  turns, and '<think>' is generation scaffolding rendered as a lone
  '</think>' on non-final turns. Now '<|user|>' / '<|assistant|>'.
- qwen3-thinking: '<think>' is stripped from non-final assistant turns
  (Qwen3-Thinking-2507) and never rendered by QwQ. Now the bare assistant
  header, matching the other qwen entries.
- zephyr: role tags are plain text and SentencePiece tokenizes them
  differently at text start than after '</s>' + newline mid-conversation;
  the markers need the leading newline anchor. Now '\n<|user|>\n' /
  '\n<|assistant|>\n'.

Validated token-level on each family's representative tokenizer with a
two-turn fixture plus system message: user and system content fully masked,
every assistant turn trained, and the final EOS label never -100. The
fixed mistral, llama, starling and glm markers produce labels identical to
zoo auto-detection; qwen3-thinking differs only in one turn-separator
newline token. All 22 unchanged entries produce byte-identical labels to
before this change.

Adds tests/test_response_template_markers.py pinning the fixed and key
unchanged marker literals (dependency-free) plus token-level masking checks
that skip when tokenizers or unsloth_zoo are unavailable offline.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request corrects several template markers in TEMPLATE_TO_RESPONSES_MAPPER (for qwen3-thinking, mistral, llama, zephyr, starling, and glm) to ensure they align with actual tokenizer outputs and prevent unintended masking of assistant tokens. It also adds a new test suite (test_response_template_markers.py) to verify these markers literally and at the token level. The feedback recommends using a context manager with explicit UTF-8 encoding when opening the tokenizer configuration file in the test helper to prevent unclosed file handles and platform-dependent encoding issues.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

return AutoTokenizer.from_pretrained(repo)
except OSError as e:
pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
except Exception:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file tokenizer_config.json is opened using open() without a with statement or explicitly closing it. This can lead to unclosed file handles, especially if an exception occurs during JSON parsing. Additionally, it is recommended to specify encoding="utf-8" when opening text files to prevent platform-dependent encoding issues (e.g., on Windows).

Using a with statement ensures the file is properly closed after reading.

Suggested change
except Exception:
with open(hf_hub_download(repo, "tokenizer_config.json"), encoding="utf-8") as f:
cfg = _json.load(f)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51925d2: the config read now uses a with block and encoding utf-8. The encoding half is the real risk since chat templates in tokenizer_config.json are rarely ASCII-only and the Windows default codec would fail the GLM fallback loader.

danielhanchen and others added 2 commits July 10, 2026 15:18
Chat templates in tokenizer_config.json are rarely ASCII-only, so the
default locale codec could fail the GLM fallback loader on Windows.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 5ebe8615f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@oobabooga

Copy link
Copy Markdown
Member

Checked the six families against the real train_on_responses_only (force_match=True, as both Studio callers use). mistral/starling/zephyr/glm/qwen3-thinking look correct. A few proposals on the rest:

1. llama looks like a no-op. On Llama-2 the [INST] are ordinary SP pieces, so old and new tokenize identically and produce byte-identical labels. The real issue seems to be the instruction side: at a turn boundary [INST] follows <s> and tokenizes as [ (29961), not standalone ▁[ (518), so the marker matches no boundary, the span runs to EOS, and the next user turn leaks. Anchoring on the BOS matched every turn cleanly in my runs (no leak, both replies trained, EOS trained). Proposal:

     "llama": {
-        "instruction": "[INST]",
+        "instruction": "<s>[INST]",
         "response": "[/INST]",
     },

If that holds up on your side, the description/comment probably shouldn't list llama as an "all-masked" case (it trains, just leaks).

2. The token test may not be running. On current transformers apply_chat_template(tokenize=True) returns a BatchEncoding, not a list, so list(ids) gives the dict keys and the later int-indexing (ids[i]) IndexErrors before any assertion runs. Suggest extracting ids first:

-    labels = fn({"input_ids": [list(ids)]})["labels"][0]
+    if hasattr(ids, "keys"):
+        ids = ids["input_ids"]
+    labels = fn({"input_ids": [list(ids)]})["labels"][0]

With that, llama would still fail its own "question two" not in trained until proposal 1 lands. (And EXPECTED_FIXED["llama"] would move to "<s>[INST]" too.)

3. Optional: vicuna/unsloth have the same trailing-space fold as starling. Latent today (no model resolves to them), but for table consistency:

     "unsloth": {
-        "instruction": ">>> User: ",
-        "response": ">>> Assistant: ",
+        "instruction": ">>> User:",
+        "response": ">>> Assistant:",
     },
     "vicuna": {
-        "instruction": "USER: ",
-        "response": "ASSISTANT: ",
+        "instruction": "USER:",
+        "response": "ASSISTANT:",
     },

Gemini's with open(..., encoding="utf-8") on the test helper is fine to fold in.

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spot-checked the mistral space-folding claim against the actual tokenizer rather than taking it on faith:

tok = AutoTokenizer.from_pretrained('unsloth/mistral-7b-instruct-v0.3')
tok.encode('[INST] hello', add_special_tokens=False)
# -> [3, 7080, 29477], decoded per-token: ['[INST]', 'hell', 'o']

Token 3 is the single [INST] special token, and there's no separate token for the space before "hello" -- it gets folded into the following text the same way the PR describes, so the old marker's trailing space wasn't recoverable as its own token boundary. That matches the stated root cause for mistral/llama.

Didn't re-derive all six template diffs at the token level (would need the GLM-4.7 and Qwen3-Thinking-2507 checkpoints too, and the PR's own new test file already does exactly that with graceful skip when a tokenizer isn't reachable), but the diff matches the table in the description exactly, and the new test_response_template_markers.py pins both the fixed entries and the unchanged ones so a future refactor can't silently regress either. Looks correct.

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went back and verified both of @oobabooga's points at the token level, since my original approval explicitly hadn't re-derived all six template diffs individually, only spot-checked mistral. Both hold up, and the current head commit hasn't addressed either yet.

1. The llama entry is still "instruction": "[INST]", unchanged. Checked against the real unsloth/llama-2-7b-chat tokenizer:

encode('[INST]') standalone -> [518, 25580, 29962] -> ['▁[', 'INST', ']']
encode('<s>[INST] hi [/INST]') -> ['<s>', '[', 'INST', ']', '▁hi', '▁[', '/', 'INST', ']']

[INST] tokenized on its own gets the space-prefixed ▁[ (518), but at the real turn boundary, right after <s>, it tokenizes as bare [ (29961) instead. A marker built from the standalone encoding never matches the actual boundary, so this entry is a no-op exactly as described, it doesn't fix the leak the PR claims to fix for this model family. Anchoring on <s>[INST] instead of [INST] is the right call.

2. The chat-template test path is broken independent of the above. Reproduced directly:

ids = tok.apply_chat_template(msgs, tokenize=True, add_generation_prompt=False)
type(ids)        # BatchEncoding, not a list
list(ids)        # ['input_ids', 'attention_mask'] -- dict keys, not token ids

So fn({"input_ids": [list(ids)]}) is being handed the string keys ['input_ids', 'attention_mask'] instead of actual token ids, and the later ids[i] indexing doesn't return an int either. This means the chat-template-based half of the test isn't validating what it looks like it's validating, which also means it wouldn't have caught issue 1.

Given the PR's whole purpose is fixing markers that don't match their rendered templates, and llama is one of the six families in scope, I'm walking back my approval until this entry and the test path are both fixed. Everything else I checked (mistral, and the general structure of the fix) still looks correct.

On transformers 5.x llama-2 tokenizes [INST] after <s> as a bare left
bracket while the standalone encoding gives the space-prefixed piece, so
the unanchored marker missed every turn boundary and later user turns
leaked into training; 4.57 masked this. Anchoring on <s>[INST] matches
both tokenizations, verified token-level under 4.57.6 and 5.5.0.

The test now unwraps the BatchEncoding that apply_chat_template returns
on 5.x before indexing, and the latent trailing spaces in the unreachable
unsloth and vicuna entries are dropped for table consistency.
@danielhanchen

Copy link
Copy Markdown
Member Author

All three landed in 5b87037, with one refinement on the diagnosis: the boundary tokenization is transformers-version dependent. On 4.57.6 llama-2 renders the turn boundary as <s> ▁[ INST ], so the unanchored [INST] actually matches and labels come out correct; on 5.x it renders <s> [ INST ] (bare bracket) and the marker misses every boundary, reproducing exactly the user-turn leak you describe. <s>[INST] matches both encodings since the anchor forces the same post-special-token context in the marker as in the render, so I took your proposal verbatim. Verified token-level under both 4.57.6 and 5.5.0: both replies trained, user turns masked, final EOS trained, no leak, and it also stops training the following turn's <s> you flagged earlier. The BatchEncoding unwrap and the unsloth/vicuna trailing-space cleanups are in the same commit; the PR description now describes llama as a leak rather than all-masked.

@danielhanchen

Copy link
Copy Markdown
Member Author

Both points are fixed in 5b87037. Reproducing your bare-bracket encode surfaced the missing variable: it is the transformers major version. 4.57.6 tokenizes the boundary as <s> ▁[ INST ] (where the old test legitimately passed and [INST] matched), while 5.x gives <s> [ INST ] and a BatchEncoding from apply_chat_template(tokenize=True), matching your outputs. The fix anchors the marker on <s>[INST] (correct labels verified token-level under both 4.57.6 and 5.5.0, no user-turn leak, final EOS trained) and the test now unwraps BatchEncoding before indexing, so the chat-template half runs on both versions. EXPECTED_FIXED and the module docstring updated accordingly.

@danielhanchen
danielhanchen requested a review from ErenAta16 July 11, 2026 12:20
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 5b870374bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the two claims I could check directly against real tokenizers rather than just reading the table (unsloth/mistral-7b-instruct-v0.3, unsloth/llama-2-7b-chat, both loadable without gating).

Mistral: rendered apply_chat_template() output has [INST] as a single token (['<s>', '[INST]', ...]). The old marker "[INST] " tokenizes to ['[INST]', '▁'] (the trailing space becomes its own token) and has zero occurrences in the real sequence. The new marker "[INST]" matches at both turn boundaries. Confirms the bug and the fix.

Llama: same idea but sharper. Old instruction marker "[INST] " tokenizes to ['▁[', 'INST', ']', '▁'], zero occurrences in the real rendered sequence. New marker "<s>[INST]" tokenizes to ['<s>', '[', 'INST', ']'] and matches at exactly the two turn starts (positions 0 and 23 in my two-turn fixture) — [INST] really does tokenize as a bare [ right after <s>, vs. ▁[ in any other context, so the anchor is necessary, not just defensive.

One thing I noticed while checking llama's response marker specifically: " [/INST]" and "[/INST]" tokenize identically here (['▁[', '/', 'INST', ']'] either way — the leading Python-string space doesn't change how SentencePiece represents it), and both match at the same positions in the real sequence. So for llama, the response_part edit in the table is cosmetic; the instruction_part <s> anchor is what actually fixes the bug. Doesn't affect correctness, just worth knowing if the table gets edited later.

Which brings up the one real discrepancy I found: the PR description's "Marker changes" table lists llama the same as mistral — '[INST] ' → '[INST]', no <s> mentioned. But the actual diff and EXPECTED_FIXED in the test file both have "llama": {"instruction": "<s>[INST]"}. The code and tests agree with each other and with what I verified above; it's just the summary table that's out of sync. Worth a one-line fix to the table so it doesn't mislead someone skimming the description later.

Didn't have time to independently verify starling/glm/qwen3-thinking/zephyr against their tokenizers the same way, but the reasoning holds up for the two I did check, and the token-level test (test_fixed_markers_token_level) applies the same real methodology — masks checked against actual rendered+tokenized fixtures, not just marker literals — to all six, plus the "final token never masked" regression check for the infinite-generation failure mode. That's the right test to have here.

Approving based on the two families I verified directly plus the soundness of the test methodology for the rest.

@danielhanchen
danielhanchen merged commit 275bad1 into main Jul 12, 2026
39 checks passed
@danielhanchen
danielhanchen deleted the studio-template-marker-fix branch July 12, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants