Model Thinking and Reasoning#
RAGFlow supports reasoning/thinking models (e.g., DeepSeek-R1, Qwen3, Claude, OpenAI o-series, Kimi) that emit chain-of-thought content before their final answer. This content flows through the system in a standardized <think>...</think> wrapper and is handled consistently across generation config, streaming, API responses, and document exports.
1. Generation Configuration#
LLMParam.gen_conf() in agent/component/llm.py builds the parameter dict sent to the model. The thinking attribute is forwarded only when it is set and not "default" :
if hasattr(self, "thinking") and self.thinking and self.thinking != "default":
conf["thinking"] = self.thinking
This is the entry point for UI-configured thinking control (e.g., enabling/disabling thinking on an LLM component). A prior bug (issue #16321, fixed in PR #16640) prevented thinking from being forwarded, so think tags were never properly controlled.
Provider-Specific Normalization#
_apply_model_family_policies() in rag/llm/chat_model.py translates the generic thinking field into each provider's native format before the API call:
| Model / Provider | Native parameter |
|---|---|
| Qwen3 (Tongyi/Dashscope) | enable_thinking (direct param or extra_body) |
| Moonshot / Kimi | {"thinking": {"type": "enabled/disabled"}} + forced top_p / penalties |
| GLM (ZHIPU AI) | {"thinking": {"type": "enabled/disabled"}} |
| OpenAI o-series | reasoning_effort |
| Anthropic | thinking |
The LITELLM_ALLOWED_GEN_CONF_KEYS set explicitly permits thinking, enable_thinking, and reasoning_effort to reach the LiteLLM completion layer . The base ALLOWED_GEN_CONF_KEYS (used by direct OpenAI-compatible clients) does not include these keys, so model-family policies must translate them into extra_body or strip them before the call .
Qwen3 defaults to enable_thinking=False unless explicitly overridden . Google Gemini models default thinking_budget=0 .
2. Streaming: Think Tag Normalization#
Providers expose reasoning in different fields (reasoning_content, reasoning). The streaming layer in _async_chat_streamly() and async_chat_streamly() normalizes these into inline <think>…</think> text chunks :
_reasoning = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None)
if _reasoning:
if not reasoning_start:
ans = "<think>"
ans += _reasoning + "</think>"
This wrapping happens for both the Base (direct OpenAI) and LiteLLMBase streaming paths . The with_reasoning=False kwarg can suppress wrapping when callers want raw text only (e.g., when QwQ is used as a non-streaming chat model) .
_stream_with_think_delta State Machine#
_stream_with_think_delta() in api/db/services/dialog_service.py is an async generator that sits between the raw LLM stream and consumers. It:
- Tracks an
in_thinkflag to distinguish reasoning from answer content. - Yields
("marker", "<think>", state)and("marker", "</think>", state)as distinct events, separate from("text", chunk, state)events. - Buffers answer text until at least
min_tokenstokens accumulate to reduce event chattering .
The _ThinkStreamState class holds the incremental parse state across chunks, including close_pending to handle cases where </think> appears at the end of a chunk without following answer text.
Both the dialog service (async_chat) and the LLM agent component (_stream_output_async, _generate_streamly) consume this generator.
3. Canvas Event Protocol (start_to_think / end_to_think)#
The Canvas.run() loop in agent/canvas.py processes each Message component's streaming output. When it receives <think> or </think> markers from _stream_with_think_delta, it emits distinct SSE events instead of text content :
if m == "<think>":
return decorate("message", {"content": "", "start_to_think": True})
elif m == "</think>":
return decorate("message", {"content": "", "end_to_think": True})
The agent API layer in api/apps/restful_apis/agent_api.py reconstructs the full content string (with tags) when accumulating full_content from these events:
if ans.get("data", {}).get("start_to_think", False):
full_content += "<think>"
elif ans.get("data", {}).get("end_to_think", False):
full_content += "</think>"
Streaming clients receive start_to_think/end_to_think flags in individual SSE chunks and are responsible for visually separating or discarding think content. The web UI renders this as a collapsible "Thinking..." section.
4. Filtering Think Content from API Responses and Exports#
Non-Streaming API Responses#
_extract_visible_answer() in dialog_service.py strips reasoning from the final accumulated text: if </think> is present, it splits at the last </think> and returns the trailing answer portion (preserving the think block only if thought content was non-empty). This is used to produce the clean answer for non-streaming chat API responses.
Known issue (now fixed): In earlier versions (≤0.25.6/0.26.1), the tag-stripping regex re.sub(r"</?think>", "", text) removed only tags, leaving reasoning text in place. _remove_reasoning_content() downstream couldn't find the now-tagless thinking text and returned it verbatim. Fixed in PR #16640 by ensuring thinking is properly forwarded through gen_conf so models called with enable_thinking=false never emit think tags at all. See issue #16321 for the full discussion.
Document Export#
Message._strip_thinking() in agent/component/message.py is applied before any Word/PDF/Excel conversion to prevent reasoning blocks from leaking into exported documents:
# Remove complete think blocks
cleaned = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
# Remove dangling unclosed <think> + trailing content
cleaned = re.sub(r"<think>.*$", "", cleaned, flags=re.DOTALL)
# Remove leftover standalone tags
cleaned = re.sub(r"</?think>", "", cleaned)
LLM Component (Non-Streaming)#
The _invoke_async method in agent/component/llm.py applies a post-processing regex to strip </think> and everything before it from structured-output answers :
ans = re.sub(r"^.*</think>", "", ans, flags=re.DOTALL)
5. Practical Notes for API Consumers#
- Non-streaming (
stream=false): Think content should be stripped automatically. If it leaks, verify your LLM component'sthinkingconfiguration is set to"disabled"(requires the PR #16640 fix to be applied). - Streaming (
stream=true): Watch forstart_to_think: trueandend_to_think: trueflags in SSE chunks. Content emitted between those flags is reasoning text. To strip it client-side:re.sub(r'<think>[\s\S]*?</think>', '', raw_answer). - Agent (
/api/v1/agents/chat/completions): In non-streaming mode, thinking tags are reconstructed infull_contentand should be stripped by_extract_visible_answer(). In streaming mode, watch forstart_to_think/end_to_thinkflags. See issue #16321 for a detailed discussion of edge cases and workarounds.