Whether R1 gives you a <think> tag to parse depends entirely on how you are running it. Through DeepSeek’s API there is no tag and no parsing to do; through the open weights there is a tag and it is not always well-formed.
Two response shapes, one model
The reasoning trace has to be delimited somehow, and DeepSeek solves that problem twice in two places. In the chat template shipped with the open weights, the trace is wrapped in literal <think>…</think> markers inside the generated text. In the hosted API, the server does that parsing for you and returns the trace in a dedicated reasoning_content field, socontent contains only the answer and contains no tags at all.
Almost every “how do I strip the think tags” question comes from somebody who read a local-inference tutorial and is calling the API, or the reverse. Establish which one you are on before writing any code: if reasoning_content is present on the message, you are on the API path and the work is already done.
The API path: a separate field
Non-streaming, both fields sit on choices[0].message. Streaming, they arrive as separate keys on choices[0].delta, and the transition between them is the signal that thinking has finished — the reasoning deltas stop and content deltas begin, with no marker in between beyond the field name changing.
from openai import OpenAI
client = OpenAI(api_key="sk-...", base_url="https://api.deepseek.com")
stream = client.chat.completions.create(
model="deepseek-reasoner",
messages=[{"role": "user", "content": "Is 2027 a prime year?"}],
stream=True,
)
thinking, answer = [], []
for chunk in stream:
d = chunk.choices[0].delta
if getattr(d, "reasoning_content", None):
thinking.append(d.reasoning_content)
elif d.content:
answer.append(d.content)
print("".join(answer))
The getattr is not defensive padding. reasoning_content is not part of the OpenAI schema the SDK models, so depending on SDK version it may arrive as an extra attribute rather than a declared one, and it is absent entirely on non-reasoning models. Reaching for it with a plain attribute access is the most common way this loop breaks when somebody switches the model name.
One consequence for user interfaces: because the trace and the answer are different fields rather than different regions of one string, you can render them into different parts of the page from the first chunk. You do not need to buffer until a closing tag arrives to know which is which.
The local path: literal tags
Run the weights under vLLM, SGLang, llama.cpp or Ollama with the model’s own chat template and the tags are in the text. The R1 model card adds a usage recommendation that matters enormously for parsing: DeepSeek advises forcing the model to begin every response with a <think> token, because the model can otherwise skip the thinking pattern and produce a response with a closing tag and no opening one.
That recommendation exists because the failure is real, and it is the single reason a naive parser breaks in production after working on every example you tried. A regular expression anchored on a matched pair silently returns nothing when the opening tag is missing, and “returns nothing” usually means your code decides the whole response is the answer — including the trace.
Some serving stacks now do the splitting themselves and expose a reasoning field to mimic the API, gated behind a flag. If you are getting neither tags nor a separate field, check your server’s reasoning-parser option before assuming the model changed.
A parser that handles both
- Decide the contract. The function takes one assistant message — either a dict from the API or a raw generated string — and returns a
(thinking, answer)pair, withthinkingpossibly empty. Never return one string with the trace still inside it; that is the bug you are trying to prevent. - Take the API field first. If the message carries a non-empty
reasoning_content, use it and return thecontentuntouched. No regular expression runs on this path. - Split on the closing tag, not the pair. On raw text, find
</think>. Everything before it is the trace, everything after is the answer. This handles the missing-opening-tag case for free, which a paired match does not. - Treat no closing tag as unfinished, not as an answer. If the response hit
max_tokensmid-trace there is no</think>at all. Returning the whole thing as the answer leaks reasoning to the user. Return an empty answer and let the caller retry. - Strip the leading whitespace, not the content. The answer typically starts with a newline after the closing tag. Trim it; do not run a general-purpose cleaner over the answer text.
import re
CLOSE = "</think>"
OPEN = "<think>"
def split_reasoning(message):
"""Return (thinking, answer) for a DeepSeek-R1 response.
Accepts an API message dict/object, or a raw generated string from
locally served open weights.
"""
# 1. Hosted API: the server already split it.
reasoning = getattr(message, "reasoning_content", None)
if reasoning is None and isinstance(message, dict):
reasoning = message.get("reasoning_content")
if reasoning:
content = getattr(message, "content", None)
if content is None and isinstance(message, dict):
content = message.get("content")
return reasoning.strip(), (content or "").strip()
# 2. Raw text from open weights.
text = message if isinstance(message, str) else (
getattr(message, "content", None)
or (message.get("content") if isinstance(message, dict) else "")
or ""
)
idx = text.find(CLOSE)
if idx == -1:
# Either the model never thought, or it was cut off mid-trace.
if OPEN in text:
return text.split(OPEN, 1)[1].strip(), "" # truncated: no answer yet
return "", text.strip() # no trace at all
thinking = text[:idx].replace(OPEN, "", 1).strip()
answer = text[idx + len(CLOSE):].strip()
return thinking, answer
if __name__ == "__main__":
cases = [
"<think>a then b</think>\nThe answer is 42.",
"a then b</think>\nThe answer is 42.", # missing opening tag
"<think>a then b", # truncated mid-trace
"The answer is 42.", # no trace at all
]
for c in cases:
print(split_reasoning(c))
Running that prints four pairs, and the second and third are the ones worth looking at: the missing-opening-tag case still yields the right answer, and the truncated case yields an empty answer rather than handing the user a paragraph of internal monologue.
The cases that break naive parsers
- A regex with
re.DOTALLand a matched pair.r"<think>(.*?)</think>"is the pattern everybody writes first. It returnsNoneexactly when the model skipped the opening tag, which is the case the model card warns about. - Tag text inside the answer. If the user asked about HTML or about R1 itself, the literal string can appear in the content. Splitting on the first closing tag rather than the last is the right default, because the trace comes first.
- Streaming a partial tag. Across chunk boundaries you can receive
</thiand thennk>. Any tag detection on a stream must run over an accumulated buffer, never over a single delta. On the API path this problem does not exist, which is the strongest practical argument for using the API’s streaming shape over local tag-scraping when both are options. - Storing the trace and replaying it. Whatever you do with the trace, it must not go back into
messageson the next turn — the API rejects that with a 400, and locally it confuses the model’s own template. Log it, display it, discard it.
What to do with the trace once you have it
Separating the trace from the answer is the easy half. Deciding what the trace is — to your users, to your logs, to your retention policy — is where teams get it wrong, and the parser is where that decision gets encoded whether you make it deliberately or not.
- Show it collapsed, if you show it. A reasoning trace is long, meandering and frequently contains rejected approaches. Put it behind a disclosure control rather than in the reading flow. The streamed form makes this easy: the trace can fill a collapsed panel while the reader waits, which turns dead time into visible progress.
- Never present it as justification. The trace is the search, not the derivation. A model can reach a correct answer through a trace containing an error, and it can produce a fluent trace supporting a wrong answer. Quoting the reasoning to a user as evidence for the conclusion is a claim the artefact does not support.
- Treat it as user data. It is generated from the user’s input and frequently restates it. Whatever retention, redaction and access rules apply to prompts apply to traces — the policy page covers the surrounding obligations. Logging traces indefinitely because they are “just debug output” is the mistake to avoid.
- Store the token count even if you drop the text. The trace is the bill. Keeping
completion_tokens_details.reasoning_tokensper request costs nothing and is the only way to answer questions about reasoning spend later, whereas storing megabytes of prose to answer the same question is expensive and unnecessary. - Do not diff it between runs. Two runs of the same prompt produce different traces by design, so a regression test that compares them will fail constantly and teach your team to ignore it.
Top comments (0)