perf: cap chat output tokens via ollama_num_predict to bound reply latency
Spoken (TTS) replies are 1-2 sentences, so an unbounded num_predict only exposes the worst case where the chat model rambles or loops. Add an ollama_num_predict config (default 512, 0 disables) wired into the reply loop's chat call on both the native- and text-tool paths. The 512-token headroom stays well above this app's short tool-call JSON, so capping never truncates a tool call. This keeps the user's quality model instead of downgrading it. Configurable in the container via OLLAMA_NUM_PREDICT. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
112
tests/test_ollama_num_predict.py
Normal file
112
tests/test_ollama_num_predict.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for the ``ollama_num_predict`` chat-output cap.
|
||||
|
||||
The cap bounds worst-case reply latency by limiting how many tokens the chat
|
||||
model may generate per turn. Spoken (TTS) answers are 1-2 sentences, so the
|
||||
default headroom never clips a normal answer and stays above tool-call JSON.
|
||||
|
||||
These tests verify behaviour:
|
||||
- the config default is present,
|
||||
- the value is threaded into the Ollama request as the ``num_predict`` option,
|
||||
- the reply loop forwards it to the chat call (and disables it at 0).
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.jarvis.config import get_default_config
|
||||
from src.jarvis.memory.conversation import DialogueMemory
|
||||
from src.jarvis.reply.engine import run_reply_engine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config default
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_default_config_has_num_predict_cap():
|
||||
config = get_default_config()
|
||||
assert config["ollama_num_predict"] == 512
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport: extra_options.num_predict reaches the Ollama payload options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("jarvis.llm.requests.post")
|
||||
def test_chat_with_messages_forwards_num_predict(mock_post):
|
||||
from jarvis.llm import chat_with_messages
|
||||
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"message": {"content": "ok"}}
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
chat_with_messages(
|
||||
"http://localhost:11434",
|
||||
"test-large",
|
||||
[{"role": "user", "content": "hi"}],
|
||||
extra_options={"num_predict": 512},
|
||||
)
|
||||
_, kwargs = mock_post.call_args
|
||||
options = (kwargs.get("json") or {}).get("options") or {}
|
||||
assert options.get("num_predict") == 512
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply loop wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mock_cfg(num_predict):
|
||||
cfg = Mock()
|
||||
cfg.ollama_base_url = "http://localhost:11434"
|
||||
cfg.ollama_chat_model = "test-large" # avoid SMALL-model text-tool path
|
||||
cfg.ollama_num_predict = num_predict
|
||||
cfg.voice_debug = False
|
||||
cfg.llm_tools_timeout_sec = 8.0
|
||||
cfg.llm_embed_timeout_sec = 10.0
|
||||
cfg.llm_chat_timeout_sec = 45.0
|
||||
cfg.llm_digest_timeout_sec = 8.0
|
||||
cfg.memory_enrichment_max_results = 5
|
||||
cfg.memory_enrichment_source = "diary"
|
||||
cfg.memory_digest_enabled = False
|
||||
cfg.tool_result_digest_enabled = False
|
||||
cfg.location_ip_address = None
|
||||
cfg.location_auto_detect = False
|
||||
cfg.location_enabled = False
|
||||
cfg.agentic_max_turns = 8
|
||||
cfg.tool_search_max_calls = 3
|
||||
cfg.tool_selection_strategy = "all"
|
||||
cfg.tool_carryover_max_turns = 2
|
||||
cfg.tool_carryover_per_entry_chars = 1200
|
||||
cfg.mcps = {}
|
||||
cfg.llm_thinking_enabled = False
|
||||
cfg.tts_engine = "none"
|
||||
cfg.ollama_embed_model = "test-embed"
|
||||
return cfg
|
||||
|
||||
|
||||
def _run_single_turn(cfg):
|
||||
"""Drive one reply turn that answers in plain text and capture the
|
||||
chat call's extra_options."""
|
||||
with patch("src.jarvis.reply.engine.plan_query", return_value=[]), \
|
||||
patch("src.jarvis.reply.engine.extract_search_params_for_memory", return_value={}), \
|
||||
patch("src.jarvis.reply.engine.extract_text_from_response", return_value="Hello."), \
|
||||
patch("src.jarvis.reply.engine.chat_with_messages") as mock_chat:
|
||||
mock_chat.return_value = {"message": {"content": "Hello."}}
|
||||
run_reply_engine(db=Mock(), cfg=cfg, tts=None,
|
||||
text="hi", dialogue_memory=DialogueMemory())
|
||||
assert mock_chat.called
|
||||
return mock_chat.call_args.kwargs.get("extra_options")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reply_loop_caps_output_when_enabled():
|
||||
extra = _run_single_turn(_mock_cfg(512))
|
||||
assert extra == {"num_predict": 512}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reply_loop_no_cap_when_zero():
|
||||
extra = _run_single_turn(_mock_cfg(0))
|
||||
assert extra is None
|
||||
Reference in New Issue
Block a user