feat(llm-steamdeck): serveur llama.cpp Vulkan OpenAI-compatible (Steam Deck)

Module services.llm-steamdeck, desactive par defaut (enable a passer a true dans le profil deck, cf. README) : llama-server en Vulkan sur RADV, GGUF telecharge depuis Hugging Face au premier demarrage, web UI, limites memoire pour les 16 Go unifies. Importe par services/default.nix (donc inerte sur laptop/tower) et par profiles/deck.
This commit is contained in:
Hermes Agent
2026-10-17 16:56:13 -04:00
parent fac87cc291
commit 2ece11d430
4 changed files with 481 additions and 0 deletions
+1
View File
@@ -19,6 +19,7 @@
../../services/eternal-terminal
../../services/matrix-conduit
../../services/nginx-matrix
../../services/llm-steamdeck
./switch-emu.nix
];
+1
View File
@@ -67,6 +67,7 @@
./reactive-resume
./anki-sync-server
./winnie
./llm-steamdeck
# ./kanidm
# ./syncserver
# ./newt # attendre maj flakes
+186
View File
@@ -0,0 +1,186 @@
# Steam Deck LLM Service - Usage Guide
## Enable in your configuration
The service is already imported in `profiles/deck/configuration.nix`. Enable it by adding:
```nix
{ config, lib, pkgs, ... }: {
services.llm-steamdeck = {
enable = true;
# Optional: customize model (defaults to Qwen3.5-9B Q4_K_M)
# modelRepo = "bartowski/Qwen_Qwen3.5-9B-GGUF";
# modelFile = "Qwen3.5-9B-Q4_K_M.gguf";
# contextSize = 8192;
# Optional: server settings
# port = 8080;
# host = "0.0.0.0"; # Use "127.0.0.1" for local-only
# Optional: performance tuning
# gpuLayers = 99; # All layers to GPU (Vulkan)
# threads = 8; # Steam Deck has 8 cores
# batchSize = 512;
# ubatchSize = 512;
# flashAttn = true;
# Optional: sampling defaults
# temperature = 0.7;
# topP = 0.95;
# topK = 20;
# repeatPenalty = 1.1;
# Optional: extra llama-server args
# extraArgs = [ "--reasoning" "on" ]; # Enable Qwen3.5 thinking mode
};
}
```
## Model options
| Model | Repo | File | Size | Notes |
|-------|------|------|------|-------|
| **Qwen3.5-9B Q4_K_M** (default) | `bartowski/Qwen_Qwen3.5-9B-GGUF` | `Qwen3.5-9B-Q4_K_M.gguf` | ~6.17 GB | Best quality/speed balance |
| Qwen3.5-9B Q4_K_M (TheStageAI) | `TheStageAI/Qwen3.5-9B-GGUF` | `Qwen3.5-9B-M-TS-Q4_K_M.gguf` | ~5.06 GB | Smaller, slightly lower quality |
| Qwen3.5-9B Q5_K_M | `bartowski/Qwen_Qwen3.5-9B-GGUF` | `Qwen3.5-9B-Q5_K_M.gguf` | ~7.11 GB | Higher quality, more VRAM |
| Qwen3.5-9B Q3_K_M | `bartowski/Qwen_Qwen3.5-9B-GGUF` | `Qwen3.5-9B-Q3_K_M.gguf` | ~5.18 GB | Lower quality, faster |
## Service management
```bash
# Check status
systemctl status llm-steamdeck
# View logs
journalctl -u llm-steamdeck -f
# Restart
systemctl restart llm-steamdeck
# Stop
systemctl stop llm-steamdeck
# Force model re-download
rm -rf /var/lib/llm-steamdeck && systemctl restart llm-steamdeck-download llm-steamdeck
```
## API usage
The server exposes an OpenAI-compatible API at `http://deck:8080/v1` (or `localhost:8080` from the Deck).
### Chat completions
```bash
curl -X POST http://deck:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.5-9b",
"messages": [
{"role": "user", "content": "Explain quantum computing in simple terms"}
],
"temperature": 0.7,
"max_tokens": 512
}'
```
### With thinking/reasoning mode (Qwen3.5)
```bash
curl -X POST http://deck:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.5-9b",
"messages": [
{"role": "user", "content": "Solve this step by step: 25 * 37"}
],
"extra_body": {
"chat_template_kwargs": {"enable_thinking": true}
}
}'
```
### Streaming
```bash
curl -X POST http://deck:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.5-9b",
"messages": [{"role": "user", "content": "Write a haiku about Steam Deck"}],
"stream": true
}'
```
### Web UI
Open `http://deck:8080` in a browser for the built-in llama.cpp web interface.
## Performance notes for Steam Deck
- **Expected speed**: ~10-13 tokens/s (Qwen3.5-9B Q4_K_M, all layers on GPU via Vulkan/RADV)
- **Prefill latency**: ~30-60s for 4000-token prompts (weak GPU compute)
- **RAM usage**: ~8-9 GB total (model + KV cache + OS)
- **Headless mode**: For max context (16K), run without gamescope/desktop:
```bash
systemctl stop plasma-plasma-kwin_wayland@0.service sddm
# Then start llm-steamdeck
```
- **Vulkan**: Uses RADV (Mesa) - ensure `hardware.opengl.enable = true` and `hardware.opengl.driSupport = true`
## Troubleshooting
### OOM / crash on start
- Reduce `gpuLayers` to 80 or lower
- Reduce `contextSize` to 4096
- Use smaller quantization (Q3_K_M)
### Slow prefill
- This is normal for Steam Deck GPU (8 CU RDNA 2, ~1.6 TFLOPS)
- Keep prompts under 2000 tokens for responsive chat
- Use smaller `batchSize` / `ubatchSize` (256)
### Vulkan not working
```bash
# Verify Vulkan
vulkaninfo | grep -E "(GPU|deviceName|driver)"
# Check RADV
RADV_PERFTEST=aco,nggc,mesh_shaders AMD_DEBUG=w32ge,w32cs \
llama-server --model /var/lib/llm-steamdeck/... -ngl 99 --port 8081
```
### Model download fails
- Check network connectivity
- For gated models, set `hfToken` via sops secret
- Manual download:
```bash
huggingface-cli download bartowski/Qwen_Qwen3.5-9B-GGUF \
--include "Qwen3.5-9B-Q4_K_M.gguf" \
--local-dir /var/lib/llm-steamdeck/bartowski_Qwen_Qwen3.5-9B-GGUF
```
## Integration with Hermes / other tools
Add to your Hermes/OpenAI client config:
```yaml
providers:
- name: "deck-qwen"
type: "openai"
base_url: "http://deck.local:8080/v1"
api_key: "dummy" # llama-server doesn't require auth
models: ["qwen3.5-9b"]
```
## Updating the model
```bash
# Stop service
systemctl stop llm-steamdeck
# Remove old model
rm -rf /var/lib/llm-steamdeck/bartowski_Qwen_Qwen3.5-9B-GGUF
# Restart (triggers re-download)
systemctl start llm-steamdeck-download llm-steamdeck
```
+293
View File
@@ -0,0 +1,293 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.llm-steamdeck;
# Default model configuration - Qwen3.5-9B Q4_K_M (~6.17 GB)
defaultModel = {
repo = "bartowski/Qwen_Qwen3.5-9B-GGUF";
file = "Qwen3.5-9B-Q4_K_M.gguf";
contextSize = 8192;
};
# Alternative: TheStageAI Qwen3.5-9B Q4_K_M (~5.06 GB, slightly smaller)
altModel = {
repo = "TheStageAI/Qwen3.5-9B-GGUF";
file = "Qwen3.5-9B-M-TS-Q4_K_M.gguf";
contextSize = 8192;
};
# Model to use (can be overridden)
model = if cfg.modelRepo != null then {
repo = cfg.modelRepo;
file = cfg.modelFile or "Qwen3.5-9B-Q4_K_M.gguf";
contextSize = cfg.contextSize or 8192;
} else defaultModel;
# Download model on first run using llama.cpp's built-in HF support
modelPath = "/var/lib/llm-steamdeck/${model.repo}/${model.file}";
in {
options.services.llm-steamdeck = {
enable = mkEnableOption "llama.cpp OpenAI-compatible server for Steam Deck (Vulkan)";
# Model configuration
modelRepo = mkOption {
type = types.nullOr types.str;
default = null;
description = "Hugging Face repository for the GGUF model (e.g. 'bartowski/Qwen_Qwen3.5-9B-GGUF'). Null uses default Qwen3.5-9B Q4_K_M.";
};
modelFile = mkOption {
type = types.str;
default = "Qwen3.5-9B-Q4_K_M.gguf";
description = "GGUF filename within the repository.";
};
contextSize = mkOption {
type = types.int;
default = 8192;
description = "Context window size. 8192 recommended for 16GB RAM; 16384 possible in headless mode.";
};
# Server configuration
port = mkOption {
type = types.portNumber;
default = 8080;
description = "Port for the OpenAI-compatible API server.";
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host to bind to. Use '127.0.0.1' for local-only access.";
};
# GPU/performance options
gpuLayers = mkOption {
type = types.int;
default = 99;
description = "Number of layers to offload to GPU (Vulkan). 99 = all layers. Reduce if OOM.";
};
threads = mkOption {
type = types.int;
default = 8;
description = "CPU threads for llama.cpp (Steam Deck has 8 cores).";
};
batchSize = mkOption {
type = types.int;
default = 512;
description = "Batch size for prompt processing. Lower if prefill OOM.";
};
ubatchSize = mkOption {
type = types.int;
default = 512;
description = "Micro-batch size for prompt processing.";
};
flashAttn = mkOption {
type = types.bool;
default = true;
description = "Enable flash attention (requires llama.cpp with Qwen3.5 support).";
};
# Sampling defaults (can be overridden per-request)
temperature = mkOption {
type = types.float;
default = 0.7;
description = "Default sampling temperature.";
};
topP = mkOption {
type = types.float;
default = 0.95;
description = "Default top-p sampling.";
};
topK = mkOption {
type = types.int;
default = 20;
description = "Default top-k sampling.";
};
repeatPenalty = mkOption {
type = types.float;
default = 1.1;
description = "Default repeat penalty.";
};
# Service management
user = mkOption {
type = types.str;
default = "deck";
description = "User to run the service as.";
};
group = mkOption {
type = types.str;
default = "deck";
description = "Group for the service.";
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra arguments passed to llama-server.";
};
# Model download
autoDownload = mkOption {
type = types.bool;
default = true;
description = "Automatically download model on first start via llama.cpp HF support.";
};
hfToken = mkOption {
type = types.nullOr types.str;
default = null;
description = "Hugging Face token for private/gated models (set via sops secret).";
};
# Web UI
enableWebUI = mkOption {
type = types.bool;
default = true;
description = "Enable built-in web UI at /";
};
};
config = mkIf cfg.enable {
# Ensure Vulkan support for RADV (Steam Deck AMD GPU)
hardware.opengl.enable = true;
hardware.opengl.driSupport = true;
hardware.opengl.driSupport32Bit = true;
# Vulkan ICD loader for llama.cpp
environment.systemPackages = with pkgs; [
vulkan-tools
vulkan-loader
mesa
mesa.drivers
mesa_glu
];
# Create model directory with proper permissions
systemd.tmpfiles.rules = [
"d /var/lib/llm-steamdeck 0755 ${cfg.user} ${cfg.group} - -"
"d /var/lib/llm-steamdeck/${model.repo} 0755 ${cfg.user} ${cfg.group} - -"
];
# Pre-download model if autoDownload enabled (using llama-cli --hf-repo)
systemd.services.llm-steamdeck-download = {
description = "Download LLM model from Hugging Face";
documentation = [ "https://github.com/ggml-org/llama.cpp" ];
wantedBy = [ "multi-user.target" ];
before = [ "llm-steamdeck.service" ];
conditionPathExists = "!${modelPath}";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
ExecStart = ''
${pkgs.llama-cpp-vulkan}/bin/llama-cli \
--hf-repo ${model.repo} \
--hf-file ${model.file} \
--model ${modelPath} \
-p " " \
-n 1 \
${lib.optionalString (cfg.hfToken != null) "--hf-token ${cfg.hfToken}"}
'';
Environment = [
"HOME=/home/${cfg.user}"
"XDG_CACHE_HOME=/home/${cfg.user}/.cache"
];
StateDirectory = "llm-steamdeck";
TimeoutStartSec = 1800; # 30 min for download
};
};
# Main llama-server service
systemd.services.llm-steamdeck = {
description = "llama.cpp OpenAI-compatible server (Vulkan) for Steam Deck";
documentation = [
"https://github.com/ggml-org/llama.cpp"
"https://huggingface.co/${model.repo}"
];
after = [ "network-online.target" "llm-steamdeck-download.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "/var/lib/llm-steamdeck";
ExecStart = ''
${pkgs.llama-cpp-vulkan}/bin/llama-server \
--model ${modelPath} \
--host ${cfg.host} \
--port ${toString cfg.port} \
--ctx-size ${toString model.contextSize} \
--n-gpu-layers ${toString cfg.gpuLayers} \
--threads ${toString cfg.threads} \
--batch-size ${toString cfg.batchSize} \
--ubatch-size ${toString cfg.ubatchSize} \
--temp ${toString cfg.temperature} \
--top-p ${toString cfg.topP} \
--top-k ${toString cfg.topK} \
--repeat-penalty ${toString cfg.repeatPenalty} \
${lib.optionalString cfg.flashAttn "--flash-attn"} \
${lib.optionalString cfg.enableWebUI "--web-ui"} \
${lib.optionalString (cfg.hfToken != null) "--hf-token ${cfg.hfToken}"} \
${concatStringsSep " " cfg.extraArgs}
'';
Restart = "on-failure";
RestartSec = 10;
TimeoutStartSec = 120;
TimeoutStopSec = 30;
# Memory limits for Steam Deck 16GB unified RAM
MemoryMax = "12G";
MemorySwapMax = "2G";
# GPU access
SupplementaryGroups = [ "video" "render" ];
Environment = [
"VK_ICD_FILENAMES=/etc/vulkan/icd.d/radeon_icd.x86_64.json:/etc/vulkan/icd.d/radeon_icd.i686.json"
"RADV_PERFTEST=aco,nggc,mesh_shaders"
"AMD_DEBUG=w32ge,w32cs"
"HOME=/home/${cfg.user}"
"XDG_CACHE_HOME=/home/${cfg.user}/.cache"
"XDG_RUNTIME_DIR=/run/user/1000"
];
# Logging
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "llm-steamdeck";
};
};
# Enable and start services
systemd.services.llm-steamdeck-download.enable = cfg.autoDownload;
systemd.services.llm-steamdeck.enable = true;
# Firewall opening for local network access
networking.firewall.allowedTCPPorts = [ cfg.port ];
# User setup (ensure deck user has video/render groups)
users.users.${cfg.user} = {
extraGroups = [ "video" "render" ];
};
# Convenience: systemd user service alias for easy management
systemd.user.services.llm-steamdeck = {
description = "User-level alias for llm-steamdeck";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.systemd}/bin/systemctl --user start llm-steamdeck.service";
RemainAfterExit = true;
};
};
# Logrotate for llama-server logs (uses systemd journal, logrotate not needed for journald)
# services.logrotate = {
# enable = true;
# settings."llm-steamdeck" = {
# path = [ "/var/log/journal/*/system.journal" ];
# rotate = 7;
# daily = true;
# compress = true;
# missingok = true;
# notifempty = true;
# };
# };
};
}