Status: research and design, checked against sources as of 2026-11-12. Nothing here has been installed or load-tested on the school’s hardware. Items marked [unverified] or [estimate] must be checked on the real machine before the first class. Sources are numbered at the bottom.
| Decision | Recommendation |
|---|---|
| Inference server | llama.cpp llama-server with parallel slots and continuous batching, bound to 127.0.0.1 |
| In front of it | buddy_proxy.py on the LAN. It handles class keys, challenge injection, the safety filter, and logs. |
| Model | Qwen3.5-4B (instruct, thinking off), GGUF Q4_K_M on 16 GB, Q5_K_M/Q6_K on 24 GB+ |
| Fallback model | Qwen3-4B-Instruct-2507: text-only, never thinks, simpler, but its KV cache is much larger (see memory) |
| Hardware | M4 with 24 GB is the sweet spot. 16 GB works for serving only. M4 Pro is about 2.3× faster per token if budget allows. |
| Service | launchd LaunchDaemons running as a dedicated non-admin user, auto-restart |
| Monitoring | llama-server --metrics (Prometheus format) + /health + the proxy’s JSONL log + a 5-minute cron health check |
| LoRA training | mlx-lm on the same mini, after school, with the server stopped or the model unloaded |
All four candidates speak the OpenAI /v1/chat/completions API. The
deciding factors for 12 kids are (a) real concurrent batching, (b) built-in
auth and metrics, and (c) how boring it is to administer.
llama.cpp llama-server |
mlx-lm mlx_lm.server |
Ollama | LM Studio (llmster) |
|
|---|---|---|---|---|
| Concurrency | -np/--parallel N slots. Continuous batching is on by default [3]. |
Has a batch generator. Apple’s WWDC26 session says the server does continuous batching [6], but docs don’t document the flags [4], and there are field reports of crashes under concurrency [7]. | Default is 1 request per model. The MLX runner serialized requests until PR #19197 (merged 2026-11-10; only in v0.40.17-rc0, not stable v0.40.16) [8][9]. | “Max Concurrent Predictions”, default 4 slots, from llama.cpp’s continuous batching (0.4.0, Jan 2026). MLX-engine parallelism came later [10][11]. |
| Speed on Apple GPU | Good. ~10–25% behind MLX on a 4B model on M4 Max (118 vs 129–159 tok/s single stream) [12]. | Fastest single stream on most models [12]. | MLX backend is now default on Apple Silicon [9][13]. | Both llama.cpp and MLX engines. |
| Auth | --api-key / --api-key-file, several keys allowed [3] |
None. The docs say “not recommended for production as it only implements basic security checks” [4]. | None found in docs [unverified], so put a proxy in front | API tokens since 0.4.0 (“Require Authentication”) [14] |
| Monitoring | --metrics Prometheus endpoint, /health, /slots [3] |
Minimal | ollama ps, logs |
GUI and lms CLI |
| Memory under load | Flat as concurrency rises [15] | Climbs. The agent workload collapsed at concurrency 16 in one benchmark [15]. | Holds ~94–97% of the Metal limit from c=1 [15] | (not benchmarked) |
| Admin ease | One binary plus flags. Pairs well with launchd. | pip install; dev tool |
Easiest install | Easiest GUI. Licensing for school use [unverified] |
| Passed all 3 audit gates in SiliconBench [15] | Yes | No | No | — |
Pick llama-server, because it is the only option that today has all
three of stable multi-slot batching, built-in key auth, and Prometheus
metrics, plus flat memory under load. MLX is a bit faster per token, but
that matters less than stability when 12 kids press Enter at the same
moment.
buddy-design.md), not for serving.Qwen3.5-4B (released 2026-03-02, Apache-2.0) is the newest ~4B Qwen [1][2]. Newer Qwen releases (3.6, 3.8) did not ship a new 4B-class open model as of this check [2][17] [verify on the Qwen HF org before install].
Things that matter for Buddy:
/think / /no_think soft switch. Disable it per request with
chat_template_kwargs: {"enable_thinking": false} [18][19]. The proxy
sends this on every request and also strips any <think> block that leaks
through. Users report the 4B “overthinks” even greetings when thinking is
left on [20].Fallback: Qwen3-4B-Instruct-2507. It is non-thinking only, so there is nothing to disable, it is text-only, and it has 36 layers [22]. Use it if Qwen3.5’s template or tooling causes trouble, e.g. if LoRA tooling lags for the hybrid architecture.
Decode on Apple Silicon is memory-bandwidth-bound, so a smaller file means faster tokens. The base M4 has 120 GB/s [23][24], so the theoretical ceiling is about 120 ÷ model-bytes tokens/s for a single stream.
| Quant (GGUF) | ~Weights for a 4B* | Single-stream ceiling on M4 (120 GB/s) | Use when |
|---|---|---|---|
| Q4_K_M | ~2.6–2.8 GB | ~40 tok/s | 16 GB mini, or speed matters most |
| Q5_K_M | ~3.1 GB | ~35 tok/s | 24 GB default: good quality/speed balance for a 4B |
| Q6_K | ~3.5 GB | ~30 tok/s | 24–32 GB, if evals show Q5 errors |
| Q8_0 | ~4.5 GB | ~25 tok/s | 32 GB; for evaluating your LoRA, not serving |
| BF16 | ~8.5 GB | ~13 tok/s | LoRA training only |
* [estimate] from ~4.2 B total parameters [25] × bits-per-weight. It excludes the vision tower, which you won’t load. Check the actual file sizes on the model page.
Small models lose more quality to 4-bit quantization than big ones, so
prefer Q5/Q6 if memory allows. Run the Buddy eval suite
(buddy-design.md §6) on each quant, because “does it still voice the
planted bug faithfully” is the metric that matters, not perplexity.
Budget = macOS + services (~3–4 GB) + model weights + KV cache for all slots
sudo sysctl iogpu.wired_limit_mb=…, but the key is undocumented by Apple
and the default percentage is reported inconsistently [26][27].
Leave it at the default unless you hit the limit.KV cache math (fp16 KV, per token = 2 × attn_layers × kv_heads × head_dim × 2 bytes):
| Model | Per token | 12 slots × 8k tokens (98k tokens total) |
|---|---|---|
| Qwen3.5-4B (8 attn layers × 4 KV heads × 256) [2] | 32 KB | ~3.2 GB (+ ~50 MB/seq of DeltaNet state [estimate]) |
| Qwen3-4B-Instruct-2507 (36 layers × 8 KV heads × 128) [config values from memory; verify in config.json] | 144 KB | ~14 GB (≈7 GB with q8_0 KV) |
That difference is why Qwen3.5-4B is the primary pick for a 16–24 GB mini.
In llama.cpp, -c is the total context shared by all slots (e.g.
-c 98304 -np 12 ≈ 8k each) [verify: the README section on per-slot
splitting was not fully read; see --kv-unified / --kv-unified-per-slot
in llama-server --help] [3].
| Mini RAM | What fits comfortably | Verdict |
|---|---|---|
| 16 GB | Qwen3.5-4B Q4_K_M (~2.7 GB) + 12 × 8k KV (~3.2 GB) + optional Qwen3Guard-0.6B (~0.6 GB Q8) ≈ 7 GB on the GPU. That is inside the default limit. | Serving only. LoRA training a 4B means stopping the server and using QLoRA. |
| 24 GB | Q5_K_M/Q6_K + 12 × 8–16k KV + guard model, with room to spare. You can keep the server up during QLoRA if the server is lightly loaded. | Recommended. Same price tier as the education 16 GB plus +$200 at launch [24]. |
| 32 GB | Q8_0 for evals, a second model (e.g. Qwen3.5-9B for the teacher), BF16 LoRA on the 4B. | Comfortable; worthwhile if Phase 2 fine-tuning is certain. |
No published benchmark covers a base M4 Mac mini serving a 4B model to 12 concurrent users. The numbers below are derived from the closest measurements, and every derived number is marked.
Measured anchors:
[estimate] for the base M4, Qwen3.5-4B Q4_K_M, llama-server, 12 slots:
| Students generating at the same moment | Aggregate tok/s | Per-student tok/s | Feels like |
|---|---|---|---|
| 1 | ~30–40 | ~30–40 | instant |
| 4 | ~60–90 | ~15–22 | fast |
| 12 (everyone hits Enter together) | ~70–120 | ~6–10 | readable. Kids read ~4–5 words/s ≈ 5–7 tok/s. |
Reasoning: the M4 has ~22% of the M4 Max’s bandwidth and a quarter of its GPU cores. Batching gains on the base chip will be smaller than the 2.6–3.7× seen on a Max, because compute saturates sooner. Time-to-first-token is the real risk. Long system prompts (Buddy’s is ~650 tokens with challenge context) must be prefilled for every request. Keep prompts short and leave llama.cpp’s prompt cache on (it reuses a shared prefix per slot).
In real classes, students are rarely all generating at once. Expect 2–5 concurrent requests.
If it’s too slow: (1) drop to Q4_K_M, (2) cap max_tokens (the proxy
defaults to 700), (3) drop to Qwen3.5-2B for Phase 0, or (4) step up to an
M4 Pro (273 GB/s ≈ 2.3× decode) [24].
# On a staff laptop on the LAN. Uses only curl + xargs, nothing to install.
# 12 parallel requests, each asking for ~300 tokens.
seq 12 | xargs -P 12 -I{} sh -c '
/usr/bin/time -p curl -s http://MINI_IP:8088/v1/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"model\":\"buddy/1.3\",\"messages\":[{\"role\":\"user\",\"content\":\"Show me your count_pairs code and explain it.\"}]}" \
-o /dev/null' 2>&1 | grep real
# Then: curl -s http://127.0.0.1:8081/metrics | grep -E "tokens_predicted|requests_processing"
Pass criteria: every request finishes within 60 s and none time out. Record the numbers in this file for the next admin.
These are the steps to perform later. Nothing was executed during this research pass. Commands assume macOS 26 and Homebrew. Pin versions you tested; record them in
infrastructure/VERSIONS.md(create on install).
buddy-mini. Give it a DHCP reservation (static IP) and an
internal DNS name on the student VLAN.sudo pmset -a sleep 0 disksleep 0. Turn on
“Start up automatically after a power failure”. Turn on automatic security
updates, but schedule macOS major upgrades manually, during breaks._buddy, or a hidden service
account, to own the model files, logs and processes.pf rule or
switch ACL that allows only TCP 8088 (the proxy) from the student VLAN,
plus SSH and Screen Sharing from the admin VLAN. Port 8081
(llama-server) stays on 127.0.0.1 only.brew install llama.cpp # provides llama-server; or use a pinned release build
mkdir -p /Users/Shared/buddy/{models,logs,keys}
# Download the GGUF ONCE on the admin network, verify its SHA256 against the
# model page, then copy it in. Record the hash in VERSIONS.md.
# /Users/Shared/buddy/models/Qwen3.5-4B-Q5_K_M.gguf
llama-server (localhost only)# -np 12 -c 98304 : 12 slots, total context (~8k each; verify split semantics)
# -ngl 99 : all layers on the GPU
# --jinja : use the model's chat template (needed for enable_thinking)
# --metrics : Prometheus /metrics
llama-server \
-m /Users/Shared/buddy/models/Qwen3.5-4B-Q5_K_M.gguf \
--alias buddy-base \
--host 127.0.0.1 --port 8081 \
-np 12 -c 98304 -ngl 99 --jinja --metrics \
--api-key-file /Users/Shared/buddy/keys/upstream.key
Flag names come from the server README [3]. Confirm them with
llama-server --help on the installed build, since flags change between
builds.
export BUDDY_HOST=0.0.0.0 BUDDY_PORT=8088 \
BUDDY_UPSTREAM=http://127.0.0.1:8081/v1 \
BUDDY_UPSTREAM_KEY="$(cat /Users/Shared/buddy/keys/upstream.key)" \
BUDDY_KEYS_FILE=/Users/Shared/buddy/keys/class-keys.txt \
BUDDY_TEACHER_KEY="$(cat /Users/Shared/buddy/keys/teacher.key)" \
BUDDY_LESSONS_DIR=/Users/Shared/buddy/zero-to-gpt/lessons \
BUDDY_LOG_DIR=/Users/Shared/buddy/logs \
BUDDY_RETENTION_DAYS=30
python3 /Users/Shared/buddy/zero-to-gpt/infrastructure/buddy/buddy_proxy.py
/Library/LaunchDaemons/org.zerotogpt.llama-server.plist (a system daemon,
so it starts at boot without anyone logged in):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>org.zerotogpt.llama-server</string>
<key>UserName</key><string>_buddy</string>
<key>ProgramArguments</key><array>
<string>/opt/homebrew/bin/llama-server</string>
<string>-m</string><string>/Users/Shared/buddy/models/Qwen3.5-4B-Q5_K_M.gguf</string>
<string>--alias</string><string>buddy-base</string>
<string>--host</string><string>127.0.0.1</string><string>--port</string><string>8081</string>
<string>-np</string><string>12</string><string>-c</string><string>98304</string>
<string>-ngl</string><string>99</string><string>--jinja</string><string>--metrics</string>
<string>--api-key-file</string><string>/Users/Shared/buddy/keys/upstream.key</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key><string>/Users/Shared/buddy/logs/llama-server.out.log</string>
<key>StandardErrorPath</key><string>/Users/Shared/buddy/logs/llama-server.err.log</string>
</dict></plist>
Create a second plist, org.zerotogpt.buddy-proxy, the same way, with
ProgramArguments = /usr/bin/python3 …/buddy_proxy.py and an
EnvironmentVariables dict holding the BUDDY_* values from §5.4. Then:
sudo chown root:wheel /Library/LaunchDaemons/org.zerotogpt.*.plist
sudo chmod 644 /Library/LaunchDaemons/org.zerotogpt.*.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/org.zerotogpt.llama-server.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/org.zerotogpt.buddy-proxy.plist
sudo launchctl print system/org.zerotogpt.llama-server | head -30 # check state
sudo launchctl kickstart -k system/org.zerotogpt.buddy-proxy # restart after config edits
GPU access from a LaunchDaemon running as a non-GUI user: Metal works headless for compute on Apple Silicon in community practice, but test this specifically [unverified]. If it fails, run the jobs as a LaunchAgent under an auto-login service account instead.
class-keys.txt (token seat-label). Rotate them each
term. A seat→child mapping, if needed, lives on paper or in the teacher’s
gradebook, not on the mini, so Buddy’s logs stay pseudonymous.BUDDY_TEACHER_KEY unlocks GET /teacher/flags.python3 -c "import secrets;print(secrets.token_urlsafe(24))".curl -s 127.0.0.1:8081/health (llama-server) and
curl -s 127.0.0.1:8088/healthz (proxy) from a cron job or launchd
StartInterval script every 5 min. On failure, run
launchctl kickstart -k and send a local notification or email to the admin.curl -s -H "Authorization: Bearer $UPSTREAM" 127.0.0.1:8081/metrics
shows tokens/s, queue depth and slot usage (Prometheus format) [3]. If
the school already runs Prometheus or Grafana, scrape it. If not,
/slots plus the proxy’s latency_ms field in the JSONL log are enough.memory_pressure and in Activity Monitor during
the load test.Do it after school. Either launchctl bootout llama-server, or keep it
up on a 24–32 GB machine with Q4 weights. The recipe is in
buddy-design.md §5. mlx-lm is pip-installed into a uv-managed venv
owned by the admin account, not _buddy.
--parallel, --cont-batching default enabled, --api-key, --api-key-file, --metrics, --slots, --kv-unified-per-slot) - https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.mdset_wired_limit docs (iogpu.wired_limit_mb) - https://ml-explore.github.io/mlx/build/html/python/_autosummary/mlx.core.set_wired_limit.html