main
py 131 lines 4.92 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # Copyright 2025 Google Inc. HuggingFace Inc. team. All rights reserved.
4
5 """Gemma4 thinking/reasoning output parsing utilities for offline inference.
6
7 Standalone functions that parse decoded model text to extract structured
8 thinking content from Gemma4 models. These are pure-Python utilities with
9 zero heavy dependencies — they work on raw decoded strings from any
10 inference backend (vLLM, HuggingFace, TGI, etc.).
11
12 For the OpenAI-compatible API reasoning parser (streaming +
13 non-streaming), see ``vllm.reasoning.gemma4_reasoning_parser``.
14 For tool call parsing, see ``vllm.tool_parsers.gemma4_utils``.
15
16 Usage with vLLM offline inference::
17
18 from vllm import LLM, SamplingParams
19 from vllm.reasoning.gemma4_utils import parse_thinking_output
20
21 llm = LLM(model="google/gemma-4-it")
22 outputs = llm.generate(prompt, SamplingParams(...))
23 text = tokenizer.decode(outputs[0].outputs[0].token_ids, skip_special_tokens=False)
24
25 # Extract thinking / answer (works with or without enable_thinking)
26 result = parse_thinking_output(text)
27 print(result["thinking"]) # chain-of-thought or None
28 print(result["answer"]) # final answer
29
30 Ported from ``transformers.models.gemma4.utils_gemma4`` so that vLLM users
31 do not need a transformers dependency for output parsing.
32 """
33
34 # ---- Thinking Mode Utility ----
35
36 # Thinking delimiter tokens as they appear in decoded text.
37 # Gemma4 uses <|channel> (start) and <channel|> (end) as thinking delimiters.
38 _THINKING_START_TAG = "<|channel>"
39 _THINKING_END_TAG = "<channel|>"
40
41 # Sentinel tokens that may appear in decoded output.
42 _TURN_END_TAG = "<turn|>"
43
44
45 def parse_thinking_output(text: str) -> dict[str, str | None]:
46 """Parse decoded Gemma4 model output.
47
48 Use this on **all** Gemma4 output regardless of whether thinking mode
49 was enabled. It handles three cases:
50
51 1. **Thinking enabled, tags present** — splits on ``<|channel>``/
52 ``<channel|>`` to separate chain-of-thought from the answer and
53 strips the ``thought\\n`` role label.
54 2. **Thinking disabled, spurious label** — strips the bare
55 ``thought\\n`` prefix that some Gemma4 models emit even
56 without thinking mode.
57 3. **Clean output** — returns the text unchanged.
58
59 The answer text is always cleaned of trailing sentinel tokens
60 (``<turn|>``, ``<eos>``, etc.).
61
62 Args:
63 text: Decoded model output text (from ``tokenizer.decode(...)``).
64
65 Returns:
66 A dict with keys:
67 - ``"thinking"``: The chain-of-thought text, or ``None`` if no
68 thinking delimiters were found.
69 - ``"answer"``: The final answer text.
70
71 Example::
72
73 >>> from vllm.reasoning.gemma4_utils import parse_thinking_output
74 >>> output_text = tokenizer.decode(outputs[0], skip_special_tokens=False)
75 >>> result = parse_thinking_output(output_text)
76 >>> print(result["thinking"]) # chain-of-thought reasoning or None
77 >>> print(result["answer"]) # final answer
78
79 """
80 if _THINKING_END_TAG in text:
81 parts = text.split(_THINKING_END_TAG, 1)
82 thinking_block = parts[0]
83 answer = _clean_answer(parts[1])
84
85 # Extract thinking content: strip the start tag if present
86 if _THINKING_START_TAG in thinking_block:
87 thinking = thinking_block.split(_THINKING_START_TAG, 1)[1]
88 else:
89 thinking = thinking_block
90
91 # Strip the "thought\n" channel role label the model emits inside
92 # <|channel>thought\n...<channel|> (analogous to "user\n" in
93 # <|turn>user\n...<turn|>).
94 thinking = _strip_thought_label(thinking.strip())
95 thinking = thinking.strip()
96
97 return {"thinking": thinking, "answer": answer}
98
99 # No thinking delimiters found.
100 # Strip spurious "thought\n" role label that some Gemma4 models sometimes
101 # emit even without thinking mode enabled, then clean trailing tokens.
102 answer = _strip_thought_label(text)
103 answer = _clean_answer(answer)
104 return {"thinking": None, "answer": answer}
105
106
107 def _strip_thought_label(text: str) -> str:
108 """Strip the spurious ``thought\\n`` label from the start of text.
109
110 Only strips when ``thought`` appears as the very first word followed by
111 a newline — preserving the word ``thought`` in any other context.
112 """
113 if text.startswith("thought\n"):
114 return text[len("thought\n") :]
115 return text
116
117
118 def _clean_answer(text: str) -> str:
119 """Clean trailing sentinel tokens from the answer text.
120
121 Strips ``<turn|>``, ``<eos>``, and surrounding whitespace that the
122 model appends at the end of its response.
123 """
124 text = text.strip()
125 # Strip trailing <turn|> (Gemma4 turn-end marker)
126 if text.endswith(_TURN_END_TAG):
127 text = text[: -len(_TURN_END_TAG)].rstrip()
128 # Strip trailing <eos> if present
129 if text.endswith("<eos>"):
130 text = text[:-5].rstrip()
131 return text