* Studio: stop chat generation on the assistant-turn-end token A small chat model (e.g. Qwen3.5-0.8B) looped on the safetensors path: it emitted a valid response or tool call, then ran past its turn and re-emitted the call, hallucinating <|im_start|>user turns. Root cause: the model's tokenizer.eos_token is synced to the config document terminator (<|endoftext|>, 248044) while chat turns actually end with <|im_end|> (248046), so generate_stream's single eos_token_id never stopped at the turn boundary. Stop on every assistant-turn-end marker the vocab defines (tokenizer.eos plus <|im_end|>, <|eot_id|>, <end_of_turn>, ...). Verified on the real weights: the single-eos control loops (400 tokens) while the fixed set yields a clean 38-token tool call and a clean answer from the tool result. No-op when eos is already the turn-ender (the id just dedups). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: repair chat generation_config.eos_token_id at load time Qwen3.5 / Qwen3.6 small chat checkpoints declare the chat turn-end as tokenizer.eos_token (<|im_end|>) but ship config.eos_token_id = <|endoftext|> and no generation_config.json (upstream shipped generation_config only on the large chat models). So every .generate() path that reads generation_config -- the vision path and tool loops, not just generate_stream -- never stops at the turn boundary and loops. At load time, when the tokenizer's own eos is a chat turn-end marker but generation_config.eos_token_id omits it, add it. This fixes the config once for all generation paths and complements the generate_stream turn-end stop. No-op for base models (eos is a plain document terminator) and already-correct configs. Verified on unsloth/Qwen3.5-0.8B: 248044 -> [248044, 248046]. * Studio: derive chat turn-end eos from the template, resolve once at load Address PR review of the turn-end stop handling: - Do not call tokenizer.get_vocab() per generation request (serializes the whole 100k+ vocab). Resolve the turn-end tokens once at load and cache them on model_info; generate_stream reads the cache. - Derive turn-end markers from the chat_template the model actually uses, not raw vocab membership, so a base/coder model that merely carries ChatML control tokens in a shared vocab is not stopped early, and a loader that synced tokenizer.eos to the document terminator is still covered. - Skip harmony/gpt-oss templates: <|end|> there is an intra-message channel delimiter, not the turn end (dropped <|return|> from the marker list too). - Move the logic to a dependency-light module (core.inference.chat_eos) so the unit test does not import the full unsloth/torch inference stack. Verified on unsloth/Qwen3.5-0.8B (gen_config 248044 -> [248044, 248046], clean 38-token tool call with generation_config-only stopping), Phi-3.5 (adds <|end|>), Llama-3 / Qwen3 (unchanged), and a harmony template (left untouched). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: refresh turn-end eos after the mapper installs its template For a MODEL_TO_TEMPLATE_MAPPER model whose own tokenizer ships no chat_template, the effective template is applied at generate time via get_chat_template, but the turn-end eos ids were resolved once at load when the template was still empty, so only the document eos was cached. Qwen2.5 / Yi base checkpoints (eos <|endoftext|>, ChatML turns end with <|im_end|>) then run past the assistant boundary in generate_stream and loop. Re-resolve the turn-end eos from the now-templated tokenizer and refresh the cached ids right after applying the mapper template, so generate_stream stops at the ChatML turn end. Add a regression test. * Studio: union turn-end eos refresh into load-time cache instead of overwriting get_chat_template can return a different tokenizer whose vocab was remapped (Gemma folds <end_of_turn> onto the eos id), while generate_stream re-reads the original model_info tokenizer. Overwriting the cache with the refreshed set dropped a valid load-time id (e.g. <end_of_turn>=107) and let generation run past the real turn marker. Union the refresh into the existing cache so it can only add ids, never drop a valid one. Add a regression test covering the destructive-swap case the prior test missed. * Studio: resolve refreshed turn-end ids on the generation tokenizer, add Gemma-4 marker Two residual gaps in the turn-end eos refresh: - For map_eos_token=True mapped templates (e.g. chatml on a Yi-6B base), get_chat_template returns a tokenizer whose vocab folds the turn-end token onto the document eos id, while generate_stream re-reads the original tokenizer. The refresh resolved ids on the returned tokenizer, so it stored the doc eos and missed the real turn-end id, and generation ran past the boundary. Read the turn-end marker strings from the mapped template but resolve their ids on the original generation tokenizer (new resolve_chat_turn_end_eos_ids_using). - Add Gemma-4's <turn|> turn terminator to the marker allowlist; those templates keep a document eos so resolve otherwise missed the real turn marker. Add regression tests for both. * Fix turn-end detection for Starling, multi-variant and vision templates; keep tests collectable The turn-end marker set missed OpenChat/Starling's barred <|end_of_turn|> (distinct from Gemma's unbarred form), so Starling generations ran past the assistant boundary. A dict/list chat_template (Hermes-3 style default+tool_use variants) hit an early non-string return and skipped detection; flatten and scan every variant. Vision models carry the chat_template on the ProcessorMixin, not the unwrapped inner tokenizer, so read markers from the template-carrying container while resolving ids on the generation tokenizer. The refresh test constructs the real backend, so it is guarded with a module-level skip when unsloth/unsloth_zoo is absent (the lightweight pytest matrix), and core.inference package init is made lazy so the dependency-light chat_eos tests collect without the heavy stack. * Studio: tighten chat turn-end eos comments * Studio: condense chat turn-end eos comments --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
109 lines
4.9 KiB
Python
109 lines
4.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Resolve a chat model's assistant-turn-end stop tokens.
|
|
|
|
Some checkpoints set eos_token_id to a bare document terminator (Qwen3.5 ships
|
|
config eos ``<|endoftext|>`` though chat turns end with ``<|im_end|>``, and its
|
|
small chat variants ship no generation_config), so generation runs past the turn
|
|
and loops -- re-emitting tool calls or hallucinating ``<|im_start|>`` turns.
|
|
|
|
Turn-end markers are derived from the tokenizer's ``chat_template`` (the tokens it
|
|
actually uses to end a turn), not raw vocab membership: a base/coder model can
|
|
carry ChatML control tokens in a shared vocab without using them, and a loader
|
|
may have synced ``eos_token`` to the document terminator. Dependency-light (no
|
|
torch / unsloth) so it is unit-testable without the full inference stack.
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
# Canonical assistant-turn-end markers per chat family.
|
|
_CHAT_TURN_END_TOKENS = (
|
|
"<|im_end|>", # ChatML: Qwen, Yi
|
|
"<|eot_id|>", # Llama 3.x
|
|
"<|eom_id|>", # Llama 3.x tool turns
|
|
"<end_of_turn>", # Gemma
|
|
"<turn|>", # Gemma-4
|
|
"<|end|>", # Phi
|
|
"<|end_of_turn|>", # OpenChat / Starling (barred, distinct from Gemma's)
|
|
)
|
|
# harmony/gpt-oss uses <|end|> as a channel delimiter, not the turn end, and has
|
|
# its own streamer, so its eos is left untouched.
|
|
_HARMONY_MARKERS = ("<|channel|>", "<|constrain|>")
|
|
|
|
|
|
def _eos_id_set(eos_token_id) -> set:
|
|
if isinstance(eos_token_id, (list, tuple)):
|
|
return {int(t) for t in eos_token_id if t is not None}
|
|
if eos_token_id is not None:
|
|
return {int(eos_token_id)}
|
|
return set()
|
|
|
|
|
|
def _collect_template_text(chat_template) -> str:
|
|
"""Flatten a tokenizer ``chat_template`` into one scannable string.
|
|
|
|
Usually the template is a single jinja string, but multi-variant models
|
|
(e.g. Hermes-3: a ``default`` plus a ``tool_use`` template) expose it as a
|
|
``{name: template}`` dict -- or, as stored in tokenizer_config.json, a list
|
|
of ``{"name": ..., "template": ...}`` dicts. Scanning only the ``str`` case
|
|
would skip turn-end detection for those valid models, so gather every string
|
|
leaf (variant names are harmless: they never contain the markers).
|
|
"""
|
|
if isinstance(chat_template, str):
|
|
return chat_template
|
|
if isinstance(chat_template, dict):
|
|
values = chat_template.values()
|
|
elif isinstance(chat_template, (list, tuple)):
|
|
values = chat_template
|
|
else:
|
|
return ""
|
|
parts = [_collect_template_text(v) for v in values]
|
|
return "\n".join(p for p in parts if p)
|
|
|
|
|
|
def resolve_chat_turn_end_eos_ids_using(template_tokenizer, id_tokenizer) -> list:
|
|
"""eos of ``id_tokenizer`` plus any canonical turn-end marker the
|
|
``template_tokenizer``'s chat_template uses, resolved to ids on ``id_tokenizer`` --
|
|
the tokenizer generation actually uses.
|
|
|
|
Pass the same tokenizer for both at load time. After a mapped ``get_chat_template``
|
|
pass the MAPPED tokenizer as ``template_tokenizer`` (it carries the effective
|
|
template) and the ORIGINAL generation tokenizer as ``id_tokenizer``: a mapped
|
|
template registered ``map_eos_token=True`` can hand back a tokenizer whose vocab
|
|
folds the turn-end token onto the doc-eos id, and generate_stream re-reads the
|
|
original tokenizer, so resolving ids on the mapped tokenizer would store the wrong
|
|
(doc-eos) id and let generation run past the real turn marker."""
|
|
ids = _eos_id_set(getattr(id_tokenizer, "eos_token_id", None))
|
|
template = _collect_template_text(getattr(template_tokenizer, "chat_template", None))
|
|
if not template or any(h in template for h in _HARMONY_MARKERS):
|
|
return sorted(ids)
|
|
unk = getattr(id_tokenizer, "unk_token_id", None)
|
|
for marker in _CHAT_TURN_END_TOKENS:
|
|
if marker in template:
|
|
try:
|
|
tid = id_tokenizer.convert_tokens_to_ids(marker)
|
|
except Exception:
|
|
tid = None
|
|
if tid is not None and tid != unk and int(tid) >= 0:
|
|
ids.add(int(tid))
|
|
return sorted(ids)
|
|
|
|
|
|
def resolve_chat_turn_end_eos_ids(tokenizer) -> list:
|
|
"""tokenizer.eos plus any canonical turn-end marker the model's chat_template
|
|
actually uses. Cheap (convert_tokens_to_ids per marker, no get_vocab); intended
|
|
to be resolved once at load. Returns eos unchanged for harmony templates."""
|
|
return resolve_chat_turn_end_eos_ids_using(tokenizer, tokenizer)
|
|
|
|
|
|
def chat_eos_repair(current_eos, turn_end_ids) -> Optional[list]:
|
|
"""Merged eos_token_id list, or None if ``current_eos`` already covers every
|
|
resolved turn-end id. Used to repair a model's generation_config at load so
|
|
every ``.generate()`` path (vision, tool loops) stops at the turn boundary."""
|
|
if not turn_end_ids:
|
|
return None
|
|
current_set = _eos_id_set(current_eos)
|
|
if set(turn_end_ids) <= current_set:
|
|
return None
|
|
return sorted(current_set | set(turn_end_ids))
|