Skip to content

The Chat Console ​

chat.py is the human-facing gateway to the engine — a minimal, dependency-light REPL that turns the raw token stream of smoe-engine into a conversation. It is deliberately thin: the Python layer owns tokenization and display, the C++ engine owns everything else.

bash
.venv/bin/python chat.py

One Engine, Many Turns ​

The console spawns smoe-engine once, in persistent server mode (--serve), and keeps it alive for the entire session. The engine's KV-cache, expert ring cache, and Scout state survive from turn to turn — so a follow-up message pays only for its new tokens, never for the conversation that came before.

Turn 1:  [full prompt prefill]           ──►  first token in ~30 s (cold)
Turn 2+: [suffix-only prefill]           ──►  first token in ~14 s
         (the entire prior conversation is already in the KV-cache)

Without a persistent process, every turn would fork a fresh engine, re-page 16 GB of Scout weights, and re-prefill the whole history from token zero — a cost that grows with every exchange. Server mode is what makes turn latency independent of conversation length.


Division of Labour ​

ConcernOwnerWhy
Chat template, tokenizationchat.py (HuggingFace AutoTokenizer)Templates are model-lineage-specific and evolve quickly; Python is the right altitude.
Multi-turn historyBoth, by contractThe console sends the full templated token stream every turn; the engine prefix-matches it against its own stored stream and prefills only the unseen suffix.
Inference, sampling, streaming, KV persistencebuild/smoe-engineThe sovereign C++ core. One process per session.
BPE-delta decodingchat.pyToken IDs stream back via --raw-ids; the console decodes incrementally and only prints once a valid UTF-8 boundary is reached (no � artifacts on multi-byte characters).

The Serve Protocol ​

The console speaks to the engine over plain stdin/stdout lines:

──► GEN <max_tokens> [ovr ...] <id0>,<id1>,...  one request per turn
◄── 32 386 12735 ...                            raw token IDs, streamed
◄── fin=<reason> <<DONE>>                       end of reply

──► RESET                                       drop all cached state
◄── <<DONE>>

◄── <<ERR bad_request>> │ <<ERR ctx_overflow>> │ <<ERR empty_suffix>> │ <<ERR suffix_too_long>>

fin=<reason> reports why generation ended — stop (the model sampled an EOS token) or length (the max_tokens budget ran out). It rides ahead of <<DONE>> as a key=value token, mirroring the request-side override syntax, so clients that only understand numbers and sentinels (the console among them) skip it without ceremony.

[ovr ...] are optional per-request sampling overrides — space-separated key=value fields between <max_tokens> and the ID list:

fieldmeaningdefault
t=<f>temperature (0 → greedy)launch --temperature
p=<f>top-plaunch --top-p
k=<u>top-k (capped at 1024)launch --top-k
r=<f>repetition penaltylaunch --rep-penalty

The first field without = starts the ID list, so clients that send the plain form are parsed unchanged; unknown keys are ignored. Launch flags remain the session defaults — an override applies to one request only.

The ID list is always the entire conversation as produced by apply_chat_template. The engine computes the longest common prefix against its stored stream:

  • Stream extended (the normal case): only the suffix is prefilled. The engine's stream includes its own previously generated tokens, so the match runs straight through the assistant's last reply.
  • History diverged (edited or regenerated): the cached KV beyond the divergence is invalid — the engine resets and prefills from scratch. Correctness never depends on the cache.

This prefix contract makes the protocol robust to chat-template quirks by construction — the console never needs to compute deltas.

Engine launch flags ​

bash
build/smoe-engine \
  --vault vault/<stem>.smoe \
  --scout vault/<stem>.scout.safetensors \
  --serve \
  --ring 0 \
  --workers 4 \
  --temperature 0.6 --top-p 0.95 --top-k 50 --rep-penalty 1.1 \
  --raw-ids
  • --serve — persistent server mode. Without it, the engine runs a single --tokens-in request and exits (still supported for scripting and benchmarks).
  • --ring 0 — delegates ring sizing to the engine's auto-tuner. The engine scans the vault for the true maximum expert blob size (Q2 blobs ≈ half the size of Q4 blobs) and budgets subtractively: the OS's own available-memory estimate (kern.memorystatus_level), minus the Scout file, a fixed engine overhead, and an OS floor — clamped to [64, 4096] slots, never below a conservative 25%-of-free fallback. A fixed slot count tuned for one quantisation level will over- or under-allocate on another; auto is the only durable setting.
  • --raw-ids — the engine emits bare token IDs on stdout. All human-readable text is produced by the console's incremental decode. Engine diagnostics live exclusively on stderr.

Two further flags exist for measurement and experimentation (not used by the console): --instrument prints a per-request decode timing breakdown on stderr — the buckets every optimization decision is priced by — and --spec N enables speculative prefetch of the next token's likely experts (default 0: measured net-negative at Q4, held in reserve for Q2 vaults).

Per-request max_tokens rides in the GEN line itself.


Failure Surfacing ​

The engine is mortal; the session is not. If the engine dies mid-turn — OOM kill, Metal failure, a bug — the console:

  1. Prints [engine died (exit N) — restarting] and respawns it.
  2. Retries the request once. The fresh engine has an empty stream, so the prefix match falls through to a full re-prefill — slower, but correct.
  3. If the turn still yields no tokens, drops the failed user turn from the conversation history, so a crashed round cannot poison the context of subsequent turns.

Ctrl-C during generation kills and respawns the engine (there is no per-request cancel in the protocol yet) — the next turn re-prefills from the template.


The Tokenizer Contract ​

One lineage, end to end

The tokenizer loaded by chat.py must come from the same checkpoint that was shattered into the vault. Qwen3's two lineages are mutually treacherous:

LineageTemplate behaviour
Qwen3-235B-A22B (thinking)Injects <think>…</think> tokens (IDs 151667/151668) via enable_thinking
Qwen3-235B-A22B-Instruct-2507 (non-thinking)No think block, no enable_thinking switch

The <think> embeddings are untrained near-zero rows in the Instruct-2507 weights (L2 ≈ 0.008 vs ≈ 1.2 for ordinary tokens). Injecting them mid-prompt collapses the residual stream: the model answers with degenerate punctuation and an early EOS. If your output looks like : or !!, audit the template before questioning the heavy engine.


Sampling Defaults ​

The console follows the Qwen3 model card recommendations for non-thinking mode:

ParameterValue
--temperature0.6
--top-p0.95
--top-k50
--rep-penalty1.1 (last 256 tokens; common punctuation, \n, and EOS IDs are exempt)

Setting --temperature below 1e-4 switches the sampler to pure greedy argmax — useful for deterministic debugging (the engine seeds its RNG at 1337).

MIT License.