-
Notifications
You must be signed in to change notification settings - Fork 154
fix(hf): parse <think> tags into mot.thinking on LocalHFBackend #1616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bf0f45e
74f5d6d
c0394e0
2f2a2fe
5c9f276
fbce642
31354cf
2ab14ea
f3c3694
3eb84c7
fb38125
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -282,6 +282,51 @@ def _cleanup_kv_cache(cache_info: HFAloraCacheInfo) -> None: | |
|
|
||
| _CHAT_TEMPLATE_THINKING_VARS: tuple[str, ...] = ("think", "thinking", "enable_thinking") | ||
|
|
||
| _THINK_OPEN_TAG: str = "<think>" | ||
| _THINK_CLOSE_TAG: str = "</think>" | ||
|
|
||
|
|
||
| def _split_think_tags(text: str) -> tuple[str | None, str]: | ||
| r"""Split raw HF output into (thinking, answer) on the closing </think> tag. | ||
|
|
||
| A string-level fallback for `transformers.PreTrainedTokenizerBase.parse_response()` | ||
| (schema-driven, token-decode-then-parse), used because no tokenizer available to | ||
| this backend today declares a `response_schema`. Even that upstream mechanism is | ||
| text-level, not token-level. | ||
|
|
||
| A token-position check against the raw generated sequence (scanning for | ||
| Granite's `</think>` token id rather than re-scanning decoded text) would be | ||
| strictly more conservative than this string match — it would not misfire on | ||
| a mention the model spells out as separate pieces rather than emitting the | ||
| single vocab token. It would not fully eliminate false positives either: a | ||
| model is free to emit that same single token for a literal in-prose mention, | ||
| so a genuine close and a canonical-token literal mention still decode | ||
| identically. Not adopted here because HF's streaming path | ||
| (`TextIteratorStreamer`) only exposes decoded text, not token ids, so a | ||
| token check could not apply uniformly to both the streaming and | ||
| non-streaming paths — see #1604 for a scoped follow-up. | ||
|
|
||
| Splits on </think> alone (not a <think>...</think> pair) because some chat | ||
| templates (e.g. granite-4.2 with enable_thinking) bake the opening tag into | ||
| the prompt, so it never appears in the model's own output. A fixed pattern | ||
| match for Granite's convention, not a general reasoning-parser; other | ||
| delimiters pass through unchanged. See #1604 for generalizing this. | ||
|
|
||
| Deliberately strips leading/trailing whitespace from both `thinking` and | ||
| `answer`: the whitespace immediately around the tags is delimiter framing, | ||
| not meaningful content, so the user-visible completion loses that framing | ||
| whitespace as part of this split, not incidentally. This framing comes | ||
| from the *generation-prompt* path (chat_template.jinja:178-182, e.g. | ||
| `<|im_start|>assistant\n<think>\n`), not the replay-reconstruction path | ||
| (chat_template.jinja:83-84) — this function only ever sees freshly | ||
| generated text, never a replayed history turn. | ||
| """ | ||
| if _THINK_CLOSE_TAG not in text: | ||
| return None, text | ||
| reasoning, _, answer = text.partition(_THINK_CLOSE_TAG) | ||
| return (reasoning.strip().removeprefix(_THINK_OPEN_TAG).strip(), answer.strip()) | ||
|
|
||
|
|
||
| # A string THINKING level (e.g. "low") is forwarded verbatim as `reasoning_effort` | ||
| # when the chat template declares that variable — this is the actual mechanism | ||
| # Granite 4.2's chat template consumes (chat_template.jinja derives its boolean | ||
|
|
@@ -1989,6 +2034,10 @@ class used during generation, if any. | |
| if isinstance(hf_output, GenerateDecoderOnlyOutput) and mot._call.model_options: | ||
| self._surface_logits(mot, hf_output) | ||
|
|
||
| # Built here (before the split below) because it needs `hf_output`'s KV cache/scores | ||
| # fields, which are cleared immediately after; cached under a key derived from | ||
| # `mot.value` further down, once the split has settled on the final string object. | ||
| cache_info: HFAloraCacheInfo | None = None | ||
| if ( | ||
| self._use_caches | ||
| and isinstance(hf_output, GenerateDecoderOnlyOutput) | ||
|
|
@@ -2009,9 +2058,6 @@ class used during generation, if any. | |
| scores=hf_output.scores, | ||
| ) | ||
|
|
||
| cache_key = id(mot.value) | ||
| self.cache_put(cache_key, cache_info) | ||
|
|
||
| # Clear KV cache and scores from HF output; retained via LRU cache above. | ||
| # `ModelOutput` (`OrderedDict` subclass) does not sync `None` writes back | ||
| # to the mapping, so plain attribute assignment leaves the dict entry — and | ||
|
|
@@ -2027,6 +2073,67 @@ class used during generation, if any. | |
| OrderedDict.__delitem__(hf_output, "logits") | ||
| hf_output.logits = None | ||
|
|
||
| # Capture the raw text before any split below. Used for the stop-string check | ||
| # further down (a stop string could itself be think-tag-adjacent, so that check | ||
| # must stay on the pre-split text) and as the input to the split itself. Do not | ||
| # repoint this at `mot.value` after the split runs. | ||
| raw_value = mot.value | ||
|
|
||
| # Gate on the template exposing a thinking var (some models think by default, so | ||
| # declaring the var isn't itself proof thinking is on) AND the resolved per-call | ||
| # value not being explicitly False (an answer that merely mentions "</think>" on a | ||
| # template with thinking off must not be split). `None`/unset must still allow the | ||
| # split: Granite and Qwen3 both default `enable_thinking` to True in their own | ||
| # template source, so treating "unset" as "off" would under-split for the common case. | ||
| # Read from `mot._call.model_options` directly (not a value already filtered for the | ||
| # template) — it's the same dict `_filter_for_chat_template` resolves | ||
| # `ModelOption.THINKING` from when building the generation-time template kwargs, so | ||
| # this mirrors what the model was actually asked to do on this call. | ||
| # Skip for streaming: astream() assumes mot.value only grows, and shrinking it here | ||
| # would corrupt the final delta (see #1604 for proper incremental splitting later). | ||
| # | ||
| # Known limitation, deliberately not addressed here: this gate can under-split | ||
| # for a model that ignores an explicit ModelOption.THINKING=False, or that always | ||
| # emits <think> blocks without declaring any of _CHAT_TEMPLATE_THINKING_VARS in its | ||
| # template. The resulting raw tags leak into mot.value and get replayed as ordinary | ||
| # `content` on the next turn — visible in the answer, and still read by requirement | ||
| # checks/judges, but not silently dropped anywhere. The alternative — splitting | ||
| # unconditionally — trades this for a worse failure: an over-split misfiles real | ||
| # answer text into mot.thinking, which gets attached as `reasoning_content` at | ||
| # replay time and is silently dropped from every subsequent prompt once a newer | ||
| # user turn exists, per Granite's own recency-based truncation (see the NOTE in | ||
| # mellea/backends/utils.py). Under-split stays diagnosable; over-split is invisible | ||
| # once replayed. Kept on the more conservative side deliberately; see #1604 for | ||
| # detecting always-thinking models without a declared var. | ||
| thinking_allowlist: frozenset[str] = getattr( | ||
| self, "_chat_template_allowlist", frozenset() | ||
| ) | ||
| resolved_thinking = ( | ||
| mot._call.model_options.get(ModelOption.THINKING) | ||
| if mot._call.model_options | ||
| else None | ||
| ) | ||
| if ( | ||
| not mot.generation.streaming | ||
| and resolved_thinking is not False | ||
| and thinking_allowlist.intersection(_CHAT_TEMPLATE_THINKING_VARS) | ||
| ): | ||
|
Comment on lines
+2111
to
+2120
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I may have been wrong about my earlier comment; since some models default to thinking on, we can't go off of model options alone here (and their truthy value). Maybe we should just always split on unless it's structured output? And then we can eventually do more sophisticated stuff like hf does with looking at the chat template, etc...
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reverting to draft. will revisit next week
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So a false split — real answer text landing in A missed split — raw tags staying in Suggest keeping the current gate on that basis — an invisible failure is worse than a visible one, even if neither is strictly safe. This reasoning is Granite-template-specific though, and the gate is generic across |
||
| thinking, answer = _split_think_tags(raw_value) | ||
| if thinking is not None: | ||
| mot.thinking = thinking | ||
|
jakelorocco marked this conversation as resolved.
|
||
| mot.value = answer | ||
| MelleaLogger.get_logger().debug( | ||
| "Split %d chars of thinking out of HF completion for %s.", | ||
| len(thinking), | ||
| self._model_id, | ||
| ) | ||
|
|
||
| if cache_info is not None: | ||
| # Keyed after the split above has settled on the final `mot.value` object, so a | ||
| # later lookup by the same key against the same (now-split) thunk can find it. | ||
| cache_key = id(mot.value) | ||
| self.cache_put(cache_key, cache_info) | ||
|
|
||
| # Only scan for tools if we are not doing structured output and tool calls were provided to the model. | ||
| if _format is None and tool_calls: | ||
| mot.tool_calls = to_tool_calls(tools, mot.value) | ||
|
|
@@ -2077,8 +2184,8 @@ class used during generation, if any. | |
| stop_strings = ( | ||
| mot._call.model_options.get(ModelOption.STOP_SEQUENCES) or [] | ||
| ) | ||
| ends_with_stop_string = isinstance(mot.value, str) and any( | ||
| mot.value.endswith(s) for s in stop_strings | ||
| ends_with_stop_string = isinstance(raw_value, str) and any( | ||
| raw_value.endswith(s) for s in stop_strings | ||
| ) | ||
| if last_token in eos_set or ends_with_stop_string: | ||
| mot.generation.finish_reasons = ["stop"] | ||
|
|
@@ -2134,6 +2241,7 @@ class used during generation, if any. | |
| "tools_available": tools, | ||
| "tools_called": mot.tool_calls, | ||
| "seed": seed, | ||
| "thinking": mot.thinking, | ||
| } | ||
| generate_log.action = mot._call.action | ||
| generate_log.result = mot | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we may need to actually look at the tokens for this to work properly? The model may produce
</think>in text form, which we wouldn't want to parse as the end of a thinking section.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looked into token-level checks — won't work here: Granite's
</think>token is itself non-special, so it's indistinguishable from literal text even at the token level.transformers' own parser has the same limit. Documented the splitter as an explicit fallback and gated it on the resolved thinking value, which cuts the practical risk.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did test this and I do see that
<think>and</think>are specific tokens. When I asked the model what it's thinking token was, it output<,think,>or something like that. So there might be more to this.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The string check is the safe superset here: it fires on every case a token check would, plus any model whose delimiter isn't a single vocab token — a token check would regress those, missing real splits entirely. Reason enough to keep it as the default mechanism on its own.
Separately, verified against
ibm-granite/granite-4.2-3b's tokenizer (one model, four test strings): encoding</think>— standalone, mid-sentence, glued to other text — always yields the same single vocab token, id100275, non-special. Generation doesn't re-encode a target string though — it samples token-by-token — so the model could in principle emit<,/think,>as separate steps decoding to the same string, which a check against the raw generated ids (hf_output.sequences[0], which includes prompt tokens and would need slicing) could tell apart from the atomic-token case, unlike a string match on decoded text.My expectation is this doesn't help the case we actually care about — a model naturally writing the tag inline is more likely to hit the same atomic token as a genuine close, since training data containing that literal substring would itself have been encoded into the atomic token. Haven't run the sampling experiment to confirm that — it's a hypothesis, not something verified like the tokenizer facts above.
The superset argument doesn't depend on that hypothesis either way — suggest keeping the string-fallback as primary. Happy to add a token check as a supplementary non-streaming-only check if you want it, but it's real complexity (guarding models without a single-token delimiter, slicing prompt tokens, a second code path, more tests) for a benefit that's currently a guess, not a fact.