townlet / backend /real.py
Budlee's picture
Dean-aligned Llama(): 5 kwargs only; hf_hub_download at module scope; poll 25s
233354f verified
Raw
History Blame Contribute Delete
5.78 kB
"""Real backends — small GGUF LLMs via llama-cpp-python.
Image generation was dropped (see backend/factory.py docstring). Only the
LLM path remains real on Spaces.
LLM loader follows the verified Build Small Discord recipe (Dean [UNRL],
2026-05-06): `Llama(...)` is constructed INSIDE the `@spaces.GPU` boundary,
not at module level.
Each `RealLLMBackend(model_id)` loads a different GGUF from the roster in
`game/models.py`. Per-model_id instances are cached by `backend/factory.py`.
Model card references (per CLAUDE.md vendor-citation rule):
https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
Additional roster URLs live in `game/models.py`.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from game.models import get_spec
from . import config
if TYPE_CHECKING:
from llama_cpp import Llama
def force_cpu_mode() -> bool:
"""When set, RealLLMBackend loads with `n_gpu_layers=0` and reduced
context — usable on the Space's 2 vCPU when ZeroGPU quota is gone.
Toggled by app.py's tick_decisions fallback path."""
return os.getenv("TOWNLET_FORCE_CPU") == "1"
class RealLLMBackend:
"""A small GGUF LLM via llama-cpp-python, keyed by `model_id` in the roster.
First call downloads the GGUF (cached after that). On Spaces with
`preload_from_hub` in README.md, the cache is warm at boot.
"""
# Module-scope per-model GGUF paths populated by app.py at boot via
# huggingface_hub.hf_hub_download — matches the canonical Dean
# (Rizz Therapy) and gokaygokay patterns. None means "resolve lazily
# via hf_hub_download on first call", which is the local-dev path.
_model_paths: dict[str, str] = {}
@classmethod
def register_model_path(cls, model_id: str, path: str) -> None:
cls._model_paths[model_id] = path
def __init__(self, model_id: str) -> None:
self.model_id = model_id
self._spec = get_spec(model_id)
self._llm: Llama | None = None
def _ensure_loaded(self) -> "Llama":
if self._llm is None:
try:
from llama_cpp import Llama
except ImportError as e:
raise RuntimeError(
"llama-cpp-python is not installed. To run with real models "
"locally, run `.venv/bin/pip install llama-cpp-python` "
"(first time compiles from source, takes 5-15 min on Mac). "
"Or run with mocks (no FORCE_REAL_MODELS env var set)."
) from e
# Resolve the GGUF path. Prefer the module-scope-cached path
# that app.py populates at boot (parent process, no GPU
# contention). Fall back to hf_hub_download here for the
# local-dev path where the cache isn't pre-populated.
path = self._model_paths.get(self.model_id)
if path is None:
from huggingface_hub import hf_hub_download
path = hf_hub_download(
repo_id=self._spec.repo_id,
filename=self._spec.gguf_filename,
)
# Five-kwarg Llama() — matches Dean (Rizz Therapy, the Discord
# canonical recipe) and 1000-Rooms. Earlier we added type_k/
# type_v/use_mlock/use_mmap/n_batch overrides; in practice
# they triggered version-gated TypeError retries on every
# fork and added latency. Stripping back to the minimal set
# eliminated that whole class of slowdown.
# n_ctx=8192 fits the 2-step smolagents memory (~5000 prompt
# + room for response). CPU-only fallback uses smaller n_ctx
# so 2 vCPU inference stays usable.
on_spaces = config.IS_SPACES
cpu_only = force_cpu_mode()
if cpu_only:
gpu_layers = 0
n_ctx = 4096
use_flash = False
elif on_spaces:
gpu_layers = -1
n_ctx = 8192
use_flash = True
else:
gpu_layers = 0
n_ctx = 6144
use_flash = False
self._llm = Llama(
model_path=path,
n_gpu_layers=gpu_layers,
n_ctx=n_ctx,
flash_attn=use_flash,
verbose=False,
)
return self._llm
def generate(self, system_prompt: str, user_prompt: str) -> str:
return self.generate_messages(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=256,
)
def generate_messages(
self,
messages: list[dict],
stop_sequences: list[str] | None = None,
max_tokens: int = 1024,
grammar: str | None = None,
) -> str:
# When `grammar` is supplied, llama.cpp constrains output to that
# GBNF. We use it for CodeAgent decisions to force the response into
# a single fenced ```py … ``` block — small chat models otherwise
# ramble and smolagents' parser fails.
# https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
llm = self._ensure_loaded()
kwargs: dict = {
"messages": messages,
"max_tokens": max_tokens,
"temperature": 0.8,
}
if stop_sequences:
kwargs["stop"] = stop_sequences
if grammar:
from llama_cpp import LlamaGrammar
kwargs["grammar"] = LlamaGrammar.from_string(grammar)
result = llm.create_chat_completion(**kwargs)
return result["choices"][0]["message"]["content"].strip()