Training
Fine-tune a local model from the Desktop app — one run at a time, with live loss and reconnect-safe progress
The Train tab turns a praisonai-train fine-tune into a form, a live loss chart, and a run history — all against your local engine.
from praisonaiagents import Agent
# After a Train-tab run finishes, point an Agent at the saved model
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
llm="ollama/yourname/my-finetune",
)
agent.start("Test my fine-tuned model")The Train tab is a desktop wrapper around the praisonai-train llm CLI. The engine writes a config.yaml, spawns python -m praisonai_train llm --config <run_dir>/config.yaml as its own process group, and reports on it live.
Quick Start
- Open the Train tab
Click Train in the sidebar. The app remembers your last-picked view, so it reopens where you left off. The one exception is a launch where the engine still needs setup or has failed — the app forces Chat while the wizard/banner is on screen, and returns to your saved view the moment the engine is healthy, in the same session (PraisonAI #4471, #4623).
- Pick a model and dataset
Defaults are
unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bitonyahma/alpaca-cleaned. Leave them as-is for your first run. - Click Start training
The button becomes Training…. A live card shows the loss chart,
step / total, current loss, and elapsed time; the log auto-follows. - Close the lid — come back
Reopen the app; the Train tab reattaches to the running job and repopulates step, loss, elapsed, and log from history.
How It Works
The engine runs one job at a time and keeps its progress in a replayable ring buffer, so closing the window never loses a run.
The engine parses two line shapes from the trainer’s output: a step / total tqdm bar and the {'loss': ..., 'learning_rate': ..., 'epoch': ...} dict trl prints each logging step. Everything else is shown verbatim in the log pane.
| Design constraint | Why |
|---|---|
| One run at a time | Two fine-tunes on one GPU OOM. A second POST /train/start returns 409 naming the live run. |
| Ring buffer, not a stream | Reconnecting reads from a cursor. If history was evicted, the engine emits resync and the UI notes the drop. |
| Adopt-in-flight | A fresh window reattaches to a run from a previous session — but only when the recorded pid + start-time fingerprint still matches the live process (mirroring what src-tauri/src/adopt.rs already did for the engine). A live pid alone is not enough — pids are recycled. A recycled pid is refused and the run is surfaced as terminal, not adopted, so Stop never SIGTERMs a stranger. A pre-upgrade state file with no pid_start is also refused, at the cost of at most one run shown as interrupted across a single restart after upgrade. |
| Stop / Quit kills the group | SIGTERM to the child’s process group (POSIX) or taskkill /T (Windows) — dataloader workers and torchrun ranks die with it. The engine’s exit handler runs the same kill before it exits, so quitting the app releases the GPU too (PraisonAI #4508, macOS/Linux). |
Reconnect & resync
Opening the tab after a gap replays from your cursor; a long run that overflowed the ring buffer tells you so rather than silently skipping output.
sequenceDiagram
participant App as 🌐 Train Tab
participant Engine as ⚙️ Engine
App->>Engine: GET /train/progress?run=<id>&cursor=N
alt N still in ring buffer
Engine-->>App: replay events from N, then follow
else N evicted
Engine-->>App: event: resync
App->>App: "… earlier output dropped; full log in run dir"
Engine-->>App: deliver oldest held events, cursor reset
endAdopting a run after restart
On restart the engine reads each runs/<id>/run.json and only adopts a run it can prove is still its own — a live pid is not enough because pids are recycled.
The fingerprint is recorded at spawn while the child is certainly still ours; reading it at adoption time would fingerprint whoever holds the pid then, which is the case being guarded against.
Choosing a method
method picks the trainer. sft works with the default dataset; the rest need differently-shaped data, named in the per-method hint under the dropdown.
method | Per-method hint (shown in the UI) | CLI reference |
|---|---|---|
sft | Trains on completions. Works with the default dataset. | Train → method |
cpt | Raw-text corpus; needs a text column. Embeddings get their own rate. | Continued Pretraining |
dpo | Needs prompt, chosen, rejected columns. | Preference Tuning |
orpo | Needs prompt, chosen, rejected; no reference model. | Preference Tuning |
kto | Needs prompt, completion, label columns. | Preference Tuning |
cpo | Needs prompt, chosen, rejected; no reference model. | Preference Tuning |
reward | Needs chosen, rejected. Trains a reward model, not a chat model. | Train (CLI) |
grpo | Needs reward functions the form cannot supply — use the CLI. | Train (CLI) |
Configuration
The form posts a config object to /train/start. Basic fields are always visible; LoRA and quantization live in the Advanced panel.
| Field | Type | Default | Notes |
|---|---|---|---|
model_name | str | unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit | Required. |
method | enum | sft | sft, cpt, dpo, orpo, kto, grpo, reward, cpo. |
dataset | list | [{name: "yahma/alpaca-cleaned", split_type: "train"}] | Required. Sent as a list even when you pick one name. |
split_type | str | train | Folded into dataset[0].split_type before posting. |
num_train_epochs | float | 1 | |
max_steps | int | 0 | Omitted from the payload when 0 — 0 means “no cap”. |
learning_rate | float | 2e-4 |
Advanced — LoRA & quantization
| Field | Type | Default | Notes |
|---|---|---|---|
lora_r | int | 16 | LoRA rank. |
lora_alpha | int | 16 | LoRA alpha. |
max_seq_length | int | 2048 | |
per_device_train_batch_size | int | 2 | |
gradient_accumulation_steps | int | 2 | |
load_in_4bit | bool | true | |
output_dir | str | outputs | Where the trainer writes the model. |
Fixed by the engine
Before writing config.yaml, the engine pins these so a desktop run stays local:
train: true
ollama_save: false
huggingface_save: falsePublishing is opt-in from the CLI — see Train → Publishing.
Environment variables
| Variable | Purpose |
|---|---|
PRAISONAI_TRAIN_CMD | Overrides the trainer launcher. Default when unset: <python> -m praisonai_train llm. --config <path> is always appended, so the override picks the interpreter, not the contract. Use it to point at a separate CUDA-matched environment. |
PRAISONAI_DESKTOP_HOME | Runs live under <home>/runs/<run_id>/{config.yaml, train.log}. See Data & Privacy. |
Common Patterns
First fine-tune
Open Train, keep the defaults, click Start training. Watch the loss drop on the live chart; the model is saved to outputs/ when the run finishes.
Interrupted run
Close the lid mid-training. Hours later, reopen the app — the Train tab reattaches, the loss chart repopulates from history, and the run finishes.
Stop a run
Click Stop. The run and all its dataloader workers die together; the status pill turns cancelled and the log tail is preserved.
Separate CUDA env
Point the app at a matched torch/CUDA environment before launching:
export PRAISONAI_TRAIN_CMD="/opt/cuda-venv/bin/python -m praisonai_train llm"The override chooses the interpreter; --config is still appended by the engine.
Reference
Every route lives on the local engine (127.0.0.1, no auth — the app’s existing design for every route).
| Method | Path | Purpose | Status codes |
|---|---|---|---|
POST | /train/start | Start a run. Body {config, run_id?}. | 200 summary; 400 missing model_name/dataset or bad run_id; 409 a run is live |
POST | /train/stop | Stop the live run. | 200 stopped; 404 nothing running |
POST | /train/stop/<run_id> | Stop a specific run. | 409 if run_id is not the live run |
GET | /train/status | Current run + last 500 metric samples. | 200 {run, metrics} |
GET | /train/runs | Last 50 run summaries. | 200 {runs} |
GET | /train/progress?run=<id>&cursor=<n> | SSE stream: replay from cursor, then follow. Keepalive comment ~every 5s. | 404 unknown run; 400 non-integer cursor |
SSE event kinds: start, state, log, progress, metric, end, plus resync when history was evicted past your cursor.
Run summary shape: {id, state, step, total, started, ended, error, elapsed, last_loss}.
state is one of:
| State | Meaning |
|---|---|
running | The trainer is live |
stopping | SIGTERM sent; teardown in progress |
done | Exited cleanly (exit 0) |
failed | Exited non-zero; error holds the last meaningful log line |
cancelled | Stopped by the user |
Best Practices
Keep max_steps small for a first run
max_steps is omitted when 0 (“no cap”). Set a small value like 10 for a fast smoke test, then raise or remove it once the pipeline is green.
One GPU runs one job
The engine refuses a second run rather than queueing. Stop the live run (or let it finish) before starting another — a 409 names the blocking run id.
Read the method hint before switching
Preference methods (dpo, orpo, kto, cpo) and reward need differently-shaped datasets. The hint under the dropdown names the required columns; the CLI pages have the full shape.
Use a separate CUDA env for the trainer
praisonai-train pulls torch and unsloth. Keep those in a matched CUDA venv and point the engine at it with PRAISONAI_TRAIN_CMD — the desktop engine itself stays stdlib-only.