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.

python
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")
Diagram

Desktop Training

📝 Form

🚀 Start

⚙️ Engine spawns praisonai-train

📈 Live loss + log

💾 outputs/ & run history

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

  1. 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).

  2. Pick a model and dataset

    Defaults are unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit on yahma/alpaca-cleaned. Leave them as-is for your first run.

  3. 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.

  4. 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.

Diagram
🤖 praisonai-train⚙️ Engine🌐 Train Tab👤 You🤖 praisonai-train⚙️ Engine🌐 Train Tab👤 Youfill form + StartPOST /train/start {config}spawn (own process group)stdout (tqdm + loss dicts)SSE start / state / log / progress / metriclive chart + log tailexit 0end (state: done)

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 constraintWhy
One run at a timeTwo fine-tunes on one GPU OOM. A second POST /train/start returns 409 naming the live run.
Ring buffer, not a streamReconnecting reads from a cursor. If history was evicted, the engine emits resync and the UI notes the drop.
Adopt-in-flightA 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 groupSIGTERM 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.

Diagram
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
    end

Adopting 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.

Diagram

no

yes

no

yes

yes

no

🔁 Restart → read run.json

Has pid_start?

⛔ Refuse — interrupted
pre-upgrade state file

pid alive?

⛔ Refuse — interrupted

pid_start matches
_pid_start_time pid?

✅ Adopt as RUNNING
Stop still reaches it

⛔ Refuse — recycled pid
do not signal a stranger

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.

Diagram

Instruction / chat

Raw-text corpus

Preference pairs

Reward model

RL with reward funcs

What are you training?

sft — default dataset

cpt — text column

dpo / orpo / kto / cpo

reward — chosen / rejected

grpo — CLI only

methodPer-method hint (shown in the UI)CLI reference
sftTrains on completions. Works with the default dataset.Train → method
cptRaw-text corpus; needs a text column. Embeddings get their own rate.Continued Pretraining
dpoNeeds prompt, chosen, rejected columns.Preference Tuning
orpoNeeds prompt, chosen, rejected; no reference model.Preference Tuning
ktoNeeds prompt, completion, label columns.Preference Tuning
cpoNeeds prompt, chosen, rejected; no reference model.Preference Tuning
rewardNeeds chosen, rejected. Trains a reward model, not a chat model.Train (CLI)
grpoNeeds 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.

FieldTypeDefaultNotes
model_namestrunsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bitRequired.
methodenumsftsft, cpt, dpo, orpo, kto, grpo, reward, cpo.
datasetlist[{name: "yahma/alpaca-cleaned", split_type: "train"}]Required. Sent as a list even when you pick one name.
split_typestrtrainFolded into dataset[0].split_type before posting.
num_train_epochsfloat1
max_stepsint0Omitted from the payload when 0 — 0 means “no cap”.
learning_ratefloat2e-4
Advanced — LoRA & quantization
FieldTypeDefaultNotes
lora_rint16LoRA rank.
lora_alphaint16LoRA alpha.
max_seq_lengthint2048
per_device_train_batch_sizeint2
gradient_accumulation_stepsint2
load_in_4bitbooltrue
output_dirstroutputsWhere the trainer writes the model.
Fixed by the engine

Before writing config.yaml, the engine pins these so a desktop run stays local:

yaml
train: true
ollama_save: false
huggingface_save: false

Publishing is opt-in from the CLI — see Train → Publishing.

Environment variables

VariablePurpose
PRAISONAI_TRAIN_CMDOverrides 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_HOMERuns 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:

bash
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).

MethodPathPurposeStatus codes
POST/train/startStart a run. Body {config, run_id?}.200 summary; 400 missing model_name/dataset or bad run_id; 409 a run is live
POST/train/stopStop 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/statusCurrent run + last 500 metric samples.200 {run, metrics}
GET/train/runsLast 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:

StateMeaning
runningThe trainer is live
stoppingSIGTERM sent; teardown in progress
doneExited cleanly (exit 0)
failedExited non-zero; error holds the last meaningful log line
cancelledStopped 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.


Related