fix(chat): gate qwen3 thinking on message content instead of always-on

The frontend hardcoded think=true on every chat send (ChatPanel full +
widget variants, KnowledgeView minichat), which defeated the _should_think
gate on the backend and made qwen3:14b spend 5-20s on chain-of-thought
reasoning for every turn — even "hi". This was the root cause of the
warm-path TTFT variance tracked in followup_ttft_variance.md: the logged
ttft_ms was really prefill + full thinking phase, bouncing with the depth
of the model's reasoning, not with cache or eviction.

All three frontend callers now pass think=false and let _should_think be
authoritative. The classifier is now a real content-based gate: explicit
think_requested=True still forces on as an override (briefing discuss
actions, future UI toggles, MCP callers), otherwise messages <80 chars
without reasoning keywords skip thinking, messages >=400 chars or
containing keywords like why/explain/analyze/debug/review/etc. get it.

Generation timing now separately records think_requested, the final
think decision, first_token_ms (first any chunk), and thinking_ms
(duration of the thinking phase). ttft_ms keeps its existing semantic
(first content token) so existing log analysis still works. The timing
log line surfaces all four fields so the old "just a big ttft number"
ambiguity is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 00:53:47 -04:00
parent 782f36ed51
commit 87fcaa6a0d
3 changed files with 80 additions and 16 deletions
+2 -2
View File
@@ -208,7 +208,7 @@ async function onSubmit(payload: { content: string; contextNoteId?: number }) {
payload.content,
payload.contextNoteId,
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
true,
false,
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
props.projectId ?? store.ragProjectId,
@@ -249,7 +249,7 @@ async function widgetSend(payload: { content: string; contextNoteId?: number })
widgetConvId.value = conv.id
emit('conversation-started', conv.id)
await store.sendMessage(payload.content, payload.contextNoteId, undefined, true)
await store.sendMessage(payload.content, payload.contextNoteId, undefined, false)
const msgs = store.currentConversation?.messages ?? []
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
+1 -1
View File
@@ -255,7 +255,7 @@ async function onMinichatSubmit(payload: { content: string; contextNoteId?: numb
}
chatOpen.value = true;
chatCollapsed.value = false;
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, true);
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
}
// ─── Auto-refresh cards when chat creates/edits notes or tasks ───────────────
+77 -13
View File
@@ -40,21 +40,65 @@ DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
# Thinking decision
# ---------------------------------------------------------------------------
#
# The `think` flag from the frontend / MCP is taken at face value: if the
# caller asked for thinking, they get thinking. No heuristic gating.
# `_should_think` is the single source of truth for whether a qwen3-class
# model should engage chain-of-thought for a given request. Frontend callers
# should NOT hardcode think=True — leave it False and let the classifier
# decide from message content. An explicit think_requested=True still acts
# as an override for callers (e.g. a future UI toggle or MCP client) that
# want to force extended reasoning regardless of content.
#
# Why gate it: on qwen3:14b, thinking adds 520s of latency before the first
# visible content token, and most conversational messages do not benefit.
# Gating by content keeps quick chats fast while preserving reasoning depth
# for prompts that actually need it.
#
# Models that don't support extended reasoning (e.g. llama3, mistral) simply
# ignore the `think` parameter in the Ollama chat request, so this is safe to
# pass unconditionally across the full model zoo.
# ignore the `think` parameter in the Ollama chat request, so the decision
# here is harmless on non-thinking models.
# Keywords that strongly suggest the user wants reasoning / analysis. Matched
# case-insensitively as whole-ish phrases.
_THINK_KEYWORDS: tuple[str, ...] = (
"why", "how does", "how do i", "how would", "how should",
"explain", "analyze", "analyse", "compare", "contrast",
"design", "architect", "architecture", "plan out", "strategize",
"debug", "diagnose", "troubleshoot", "root cause",
"review", "critique", "evaluate", "trade-off", "tradeoff", "trade off",
"pros and cons", "step by step", "walk me through",
"prove", "derive", "figure out", "work through",
)
# Messages shorter than this and without any think-keyword are treated as
# simple/conversational and skip the thinking phase.
_SHORT_MESSAGE_CHARS = 80
# Messages longer than this are treated as substantive regardless of keywords.
_LONG_MESSAGE_CHARS = 400
def _should_think(user_content: str, think_requested: bool) -> bool:
"""Return whether extended thinking should be used for this request.
Honors the caller's request directly — no message-complexity classifier.
The frontend toggle / MCP `think` parameter is the source of truth.
``think_requested`` acts as an explicit override: if True, thinking is
forced on regardless of content. If False (the default), the decision is
made by inspecting the message: long or keyword-bearing messages get
thinking; short conversational messages skip it.
"""
return bool(think_requested)
if think_requested:
return True
text = (user_content or "").strip()
if not text:
return False
if len(text) >= _LONG_MESSAGE_CHARS:
return True
lowered = text.lower()
if any(kw in lowered for kw in _THINK_KEYWORDS):
return True
if len(text) < _SHORT_MESSAGE_CHARS:
return False
# Medium-length message with no obvious reasoning cue: default off.
return False
# Human-readable labels for each tool, shown in the status indicator
@@ -244,17 +288,23 @@ async def run_generation(
# Emit context event
buf.append_event("context", {"context": context_meta})
# Apply thinking classifier — downgrade think=True for simple/conversational messages
think = _should_think(user_content, think)
# `_should_think` is authoritative — frontend callers pass think=False by
# default and let this classifier decide based on message content. An
# explicit think=True still forces on as an override.
think_requested = think
think = _should_think(user_content, think_requested)
t_start = time.monotonic()
timing: dict = {
"think_requested": think_requested,
"think": think,
"num_ctx": num_ctx,
"tools": [],
"rounds": 0,
"prompt_tokens": None,
"output_tokens": None,
"first_token_ms": None,
"thinking_ms": None,
"ttft_ms": None,
"generation_ms": None,
"total_ms": None,
@@ -286,11 +336,21 @@ async def run_generation(
break
if chunk.type == "thinking":
if timing["first_token_ms"] is None:
timing["first_token_ms"] = int((time.monotonic() - t_start) * 1000)
buf.append_event("thinking_chunk", {"chunk": chunk.content})
elif chunk.type == "content":
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
now_ms = int((time.monotonic() - t_start) * 1000)
timing["ttft_ms"] = now_ms
if timing["first_token_ms"] is None:
# No thinking phase occurred — first token IS content.
timing["first_token_ms"] = now_ms
else:
# Thinking phase duration = gap between first thinking
# token and first content token.
timing["thinking_ms"] = now_ms - timing["first_token_ms"]
buf.content_so_far += chunk.content
clean = _TOOL_CALL_MARKER.sub("", chunk.content)
if clean:
@@ -414,9 +474,13 @@ async def run_generation(
timing["total_ms"] = int((time.monotonic() - t_start) * 1000)
logger.info(
"Generation timing for conv %d: total=%dms ttft=%s tools=%s generation=%s",
conv_id, timing["total_ms"], timing["ttft_ms"],
[(t["name"], t["ms"]) for t in timing["tools"]], timing["generation_ms"],
"Generation timing for conv %d: total=%dms think=%s(req=%s) first_token=%s "
"thinking=%s ttft=%s generation=%s tools=%s",
conv_id, timing["total_ms"],
timing["think"], timing["think_requested"],
timing["first_token_ms"], timing["thinking_ms"], timing["ttft_ms"],
timing["generation_ms"],
[(t["name"], t["ms"]) for t in timing["tools"]],
)
try:
await log_generation(user_id, conv_id, model, timing)