Model quality improves faster when iteration is cheap. A dedicated RTX 5060 Ti 16GB on our dedicated GPU hosting gives you a card that is always warm, always available, and costs a flat monthly fee – which makes “try it” the default response rather than “queue it”. This guide sets out a fine-tune/eval loop optimised for iteration speed, not production serving.
Contents
The Loop
- Collect new examples from production logs or user feedback
- Clean, dedupe, and shuffle into train/eval splits
- Fire a LoRA or QLoRA run with a fresh experiment ID
- Evaluate on a fixed held-out set and compare to current champion
- If better: hot-swap the adapter into the serving vLLM without restart
- Monitor, gather next batch of feedback, loop
Dataset Prep Scripts
Keep prep scripts in a repo so every run is reproducible. A minimal pipeline:
# dataset_prep.py
import json, hashlib, random
from pathlib import Path
def load_prod_logs(path):
return [json.loads(l) for l in Path(path).read_text().splitlines()]
def to_chatml(row):
return {"messages":[
{"role":"user","content":row["prompt"]},
{"role":"assistant","content":row["response"]},
]}
def dedupe(rows):
seen = set(); out = []
for r in rows:
h = hashlib.sha256(json.dumps(r,sort_keys=True).encode()).hexdigest()
if h not in seen:
seen.add(h); out.append(r)
return out
rows = dedupe([to_chatml(r) for r in load_prod_logs("logs.jsonl")])
random.seed(42); random.shuffle(rows)
split = int(len(rows)*0.9)
Path("train.jsonl").write_text("\n".join(json.dumps(r) for r in rows[:split]))
Path("eval.jsonl" ).write_text("\n".join(json.dumps(r) for r in rows[split:]))
print(f"train={split} eval={len(rows)-split}")
Per-Experiment LoRA
One adapter per experiment, named and logged. Pattern:
| Field | Example |
|---|---|
| Experiment ID | 2026-04-23-qlora-r16-v7 |
| Base model | Llama 3.1 8B |
| Training config | r=16, alpha=32, lr=2e-4, 3 epochs |
| Dataset hash | sha256 of train+eval jsonl |
| Wandb run URL | logged |
| Eval result | F1 0.84, BLEU 31.2, cost 28 min |
| Adapter path | /models/adapters/2026-04-23-v7/ |
Run with Unsloth:
EXPID=$(date +%Y-%m-%d)-qlora-r16-v$(git rev-parse --short HEAD)
python train.py --output ./runs/$EXPID --data ./data \
--base unsloth/Meta-Llama-3.1-8B-Instruct --r 16 --lr 2e-4 --epochs 3
Eval Harness
Keep the eval set fixed across experiments. Changing the eval set invalidates cross-run comparison. A simple scorer:
import json, subprocess
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="x")
scores = []
for ex in map(json.loads, open("eval.jsonl")):
pred = client.chat.completions.create(
model="candidate", messages=ex["messages"][:-1], max_tokens=256
).choices[0].message.content
gold = ex["messages"][-1]["content"]
scores.append(score_fn(pred, gold))
print(f"mean={sum(scores)/len(scores):.3f}")
Run eval against the new candidate adapter and the current champion; promote only on improvement.
Hot-Swap Deploy
LoRAX allows atomic adapter swaps without stopping the server. Traffic continues on the base model while the new adapter registers:
curl -X POST http://localhost:8000/v1/lora_adapters \
-H "Content-Type: application/json" \
-d '{"lora_name":"2026-04-23-v7","lora_path":"/models/adapters/2026-04-23-v7"}'
Clients choose the adapter via the model field in the OpenAI request body. Canary the new one behind a feature flag before cutting over fully.
Cycle Time Budget
| Step | Budget | Notes |
|---|---|---|
| Dataset refresh | 10 min | Scripted |
| LoRA train (Mistral 7B, 2k examples, 3 epochs) | ~30 min | Unsloth, BF16 |
| QLoRA train (Qwen 14B, 5k examples) | ~2 h | NF4, BF16 compute |
| Eval pass (500 examples) | ~10 min | Against vLLM endpoint |
| Hot-swap deploy | <5 s | LoRAX API |
| Total small-model cycle | ~1 hour | Multiple per day feasible |
| Total large-model cycle | ~3 hours | Still one per afternoon |
Fast Fine-Tune Iteration
Dedicated Blackwell 16 GB, no training queue. UK dedicated hosting.
Order the RTX 5060 Ti 16GBSee also: LoRA guide, QLoRA guide, Unsloth speed, fine-tune throughput, vLLM setup.