perf: unify Ollama num_ctx so a voice turn keeps one resident model

Ollama keeps a separate loaded model instance per (model, num_ctx). The
main agentic chat used num_ctx=8192 while the router/enrichment/digest
passes used 4096, so every voice turn forced at least one cold reload
(~3.4s) when switching context sizes — the dominant per-turn latency
(measured: resident chat call 0.27s vs cold 3.4s).

Introduce a single OLLAMA_NUM_CTX (default 8192, env-tunable for tight
VRAM) used by call_llm_direct, chat_with_messages, call_llm_streaming and
the planner, collapsing a turn to one resident instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-14 00:19:53 +09:00
parent d4e5e7f3f7
commit 2c38e7576d
2 changed files with 23 additions and 10 deletions

View File

@@ -2,24 +2,37 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional, Any, Dict, List, Generator, Callable from typing import Optional, Any, Dict, List, Generator, Callable
import os
import requests import requests
import json import json
from .debug import debug_log from .debug import debug_log
# Single context-window size shared by EVERY Ollama call (chat, router,
# enrichment, digests, streaming). Ollama keeps a SEPARATE loaded model
# instance per (model, num_ctx): mixing 4096 and 8192 in one voice turn made
# it evict and cold-reload the model (~3.4s each) on every context switch —
# the dominant per-turn latency. Keeping one value collapses this to a single
# resident instance, so only the very first call of a cold model pays a load.
# 8192 is the floor the main agentic chat needs (system prompt + tool schema +
# memory) without silent truncation. Tunable via env for tight-VRAM hosts.
OLLAMA_NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "8192"))
class ToolsNotSupportedError(Exception): class ToolsNotSupportedError(Exception):
"""Raised when the model returns HTTP 400 because native tool calling is not supported.""" """Raised when the model returns HTTP 400 because native tool calling is not supported."""
pass pass
def call_llm_direct(base_url: str, chat_model: str, system_prompt: str, user_content: str, timeout_sec: float = 10.0, thinking: bool = False, num_ctx: int = 4096, temperature: Optional[float] = None) -> Optional[str]: def call_llm_direct(base_url: str, chat_model: str, system_prompt: str, user_content: str, timeout_sec: float = 10.0, thinking: bool = False, num_ctx: int = OLLAMA_NUM_CTX, temperature: Optional[float] = None) -> Optional[str]:
"""Direct LLM call without temporal context, location, or other ask_coach features. """Direct LLM call without temporal context, location, or other ask_coach features.
``num_ctx`` controls Ollama's context window for this call. Default 4096 is ``num_ctx`` controls Ollama's context window for this call. It defaults to
fine for small classification-shaped passes; callers that assemble richer the shared ``OLLAMA_NUM_CTX`` so small classification-shaped passes load the
prompts (planner with dialogue + memory + tool catalogue) should pass a SAME Ollama instance as the main chat loop (no cold reload on context
larger value to avoid silent truncation. switch). Callers may still override it, but diverging from the shared value
reintroduces a per-turn model reload.
``temperature`` is forwarded to Ollama when set. Pass ``0.0`` for ``temperature`` is forwarded to Ollama when set. Pass ``0.0`` for
classification / extraction calls where determinism beats creativity — classification / extraction calls where determinism beats creativity —
@@ -102,7 +115,7 @@ def call_llm_streaming(
"model": chat_model, "model": chat_model,
"messages": messages, "messages": messages,
"stream": True, "stream": True,
"options": {"num_ctx": 4096}, "options": {"num_ctx": OLLAMA_NUM_CTX},
"think": thinking, "think": thinking,
# Keep the chat model resident between calls (see call_llm_direct). # Keep the chat model resident between calls (see call_llm_direct).
"keep_alive": "30m", "keep_alive": "30m",
@@ -207,7 +220,7 @@ def chat_with_messages(
"model": chat_model, "model": chat_model,
"messages": messages, "messages": messages,
"stream": False, "stream": False,
"options": {"num_ctx": 8192}, "options": {"num_ctx": OLLAMA_NUM_CTX},
"think": thinking, "think": thinking,
# Keep the chat model resident between turns (see call_llm_direct). # Keep the chat model resident between turns (see call_llm_direct).
"keep_alive": "30m", "keep_alive": "30m",

View File

@@ -40,7 +40,7 @@ import re
from typing import List, Optional, Sequence, Tuple from typing import List, Optional, Sequence, Tuple
from ..debug import debug_log from ..debug import debug_log
from ..llm import call_llm_direct from ..llm import call_llm_direct, OLLAMA_NUM_CTX
# Hard cap on plan length. Small models happily emit 10+ step plans that # Hard cap on plan length. Small models happily emit 10+ step plans that
@@ -441,7 +441,7 @@ def plan_query(
user_content=user_content, user_content=user_content,
timeout_sec=effective_timeout, timeout_sec=effective_timeout,
thinking=False, thinking=False,
num_ctx=8192, num_ctx=OLLAMA_NUM_CTX,
) )
except Exception as exc: # pragma: no cover — defensive except Exception as exc: # pragma: no cover — defensive
debug_log(f"planner: LLM call failed — {exc}", "planning") debug_log(f"planner: LLM call failed — {exc}", "planning")
@@ -716,7 +716,7 @@ def resolve_next_tool_call(
user_content=user_content, user_content=user_content,
timeout_sec=effective_timeout, timeout_sec=effective_timeout,
thinking=False, thinking=False,
num_ctx=8192, num_ctx=OLLAMA_NUM_CTX,
) )
except Exception as exc: # pragma: no cover — defensive except Exception as exc: # pragma: no cover — defensive
debug_log(f"planner.resolve_next_tool_call: LLM failed — {exc}", "planning") debug_log(f"planner.resolve_next_tool_call: LLM failed — {exc}", "planning")