Finetuning#
A working recipe for fine-tuning a small open model on your own gptme
conversations, in gptme’s own tool formats, and then measuring whether it
actually got better with gptme-eval.
Everything below was run end to end on 2026-09-08/09 against a real corpus of agent sessions. The numbers, the config keys and the gotchas are measured, not sketched. Where something was not measured, this page says so.
What this is not. It is not a cost-optimization guide — fine-tuning a 0.8B model will not make gptme cheaper than a frontier API, and that is not why you would do this. It is not a production pipeline either: there is no scheduler, no data versioning, no retraining loop. It is a recipe you can follow once, in an afternoon, for about $15 of rented GPU, to find out what your own trajectories are worth as training data.
The single most useful result from the run: one epoch of SFT on your own
sessions rendered in gptme’s markdown format took a Qwen3.5-0.8B from 10% to
19% on gptme-eval, and the gain transferred to native tool calling (15% → 22%).
The full table, and the caveats it needs, are in Results below.
Data#
Where the conversations are#
gptme stores every conversation as ~/.local/share/gptme/logs/<name>/conversation.jsonl,
one JSON object per line: {role, content, timestamp}. That is your corpus.
There is no separate export step and no telemetry — the logs are already on
disk.
Two tool-call encodings, and why that matters#
Tool calls appear in conversation.jsonl in two different text encodings,
and a parser that matches only one will silently drop whole sessions:
Markdown fences — the default
markdowntool format. The assistant message contains a fenced block whose language tag is a tool name (shell,ipython,patch <path>,save <path>), and the result arrives as the followingsystemmessage.The native form —
@tool_name(call_id): {json}at the start of a line, which is how gptme renders provider-side tool calls back into the log (see Tool Formats).
Both live in the same corpus, often in the same directory tree. A previous
analysis pass that grepped only for fences reported 17 loop patterns across 50
sessions; adding the @tool(id): {json} branch took the same corpus to 341.
The “agents don’t retry” conclusion drawn from the first number was a parser
artifact. Match both:
from gptme.tools.base import find_json_end, toolcall_re
# fence form (markdown tool format): ```shell\n<code>\n```
# native form: @shell(abc123): {"command": "ls"}
# toolcall_re only finds the START of a call. Its `{.*` capture is DOTALL and
# greedy, so group(3) is not JSON and finditer() swallows later parallel
# calls in the same message. Resume from find_json_end after each match.
# MCP tools are named server.tool (dots). Provider call IDs include
# hyphens/colons (call_abc, toolu_..., call-123). `\w+` drops those.
def iter_native_calls(content: str):
search_from = 0
while match := toolcall_re.search(content, search_from):
json_start = match.start(3)
json_end = find_json_end(content, json_start)
if json_end is None:
break
yield match.group(1), match.group(2), content[json_start:json_end]
search_from = json_end
If you also mine Claude Code trajectories, note that those are a third shape
entirely — structured tool_use / tool_result content blocks in
~/.claude/projects/<slug>/*.jsonl — and need their own parser, not a text
match.
The export format#
Target Axolotl’s chat_template dataset type. One JSON object per line:
{"messages": [...], "tools": [...]}
messagesuses the OpenAI shape. Assistant tool calls go intool_calls[].function, tool results are{"role": "tool", "tool_call_id", "name", "content"}.toolsis the JSON-schema tool list, only needed for the native format.argumentsmust be a JSON string, not an object. If you nest a real object there, Hugging Facedatasetsfails to cast the column and the run dies during preprocessing, not during training — so you pay for the box first. This is the single most common export bug.
The three renderers, and why paired datasets#
gptme has three tool formats, and they are genuinely different token distributions, not cosmetic variants. So the exporter should be able to render the same sessions into each of them:
Renderer |
Assistant turn |
Tool result |
|---|---|---|
|
fenced block with the tool name as the language tag |
following |
|
|
same, as a |
|
OpenAI |
|
Emit all three from the same sessions with the same train/holdout split. That is what makes a format comparison meaningful: a markdown-trained adapter must not be judged on xml output, and without paired datasets you cannot tell a format effect from a data effect.
Masking, truncation, sequence length#
Assistant-only loss. Train on assistant turns only (
roles_to_train: ["assistant"], which is Axolotl’s default). On this data shape roughly 9% of tokens end up trainable — the system prompt and every tool result are masked. That is expected, not a bug in your masking.The gptme system prompt is large: ~7–8.5k tokens for a normal tool allowlist (7.7k in the run below). It is a constant shared by every row, so do not count it against your per-conversation truncation budget — but do size
sequence_lenas conversation budget + system prompt. An 8k conversation budget wantssequence_len: 16384, not 8192, or every row is silently truncated to system prompt plus a couple of turns.End every row on an assistant turn. A row that ends on a tool result has no trainable final token and teaches the model to stop mid-call. In the run below this hit 1 in 8 rows before it was caught.
Hold out by time, not at random. Use later months as the holdout. A random split over agent sessions leaks: the same task, the same repo and often near-identical tool sequences appear on both sides, and the eval measures memorization.
Strip per-row
meta. Any bookkeeping block you attach per row (grades, session ids, model names) will eventually contain a column that isnullin the first rows and a float later — HFdatasetscannot cast that and the job fails at preprocess time. Keep the metadata in a separate sidecar file, or emit the training JSONL with metadata off.
Redaction#
Agent trajectories are full of secrets: API keys echoed by a shell call,
tokens in environment dumps, credentials in config files the agent cated.
Redact before the data leaves the machine, and treat these three findings as
load-bearing:
Scrub decoded leaf strings, never the serialized
argumentsblob. Running a regex over the JSON string corrupts escaping and produces rows that no longer parse. Walk the structure, redact the leaves, re-serialize.Allowlist your own workspace paths. Blanket path redaction destroys the thing you are training on — the model needs to learn that files live at real paths. Redact everything except an explicit allowlist (e.g.
/home/you/).A secret scanner’s “blocked” verdict is not fail-closed. The
openai_keyrule false-positives on any filename containingsk-, so a fail-closed policy would have silently dropped good sessions. Review the findings; do not wire the scanner straight to a drop filter.
Validate after export: zero call/result pairing violations, zero non-string
arguments, zero residual secrets, zero holdout-month leakage. All four are
cheap to check and all four have been violated in practice.
Tooling#
gptme ships scripts/train/collect.py, the legacy collector: it reads the
same logs, filters generated-name and low-quality chats, strips leading system
prompts, and renders each conversation through a Hugging Face chat template to
train.csv / train.jsonl in the current working directory. It predates
gptme’s tool formats and native tool calling, so it does not emit tool_calls,
does not emit a tools column, and cannot render the three formats. It is fine
for a plain chat-style SFT and wrong for anything tool-shaped.
The run below used a private exporter that implements Data above — it is
not in this repository. Reimplement from that section; collect.py is not a
substitute for tool-shaped SFT. The exporter that produced these numbers
reads gptme logs and Claude Code trajectories, joins session-level labels,
does the redaction pass, and writes paired markdown/xml/tool JSONL
from the same sessions with no per-row metadata (the HF-datasets problem
above).
Training#
Why Axolotl#
Axolotl (v0.18.0 at time of writing), on this data shape:
Axolotl — native OpenAI-style
messageswithtool_callsplus atoolscolumn, assistant-only loss by default, char-offset masking if you need it, and a cloud image that ships SSH and tmux for rented boxes. Best fit.TRL — a strong second, “less magic”.
assistant_only_loss=Trueneeds{% generation %}markers in the chat template; TRL auto-patches Qwen3/3.5, Gemma 3, Llama 3, gpt-oss and others, but you hand-patch anything else.LLaMA-Factory — popular, but forces strict human/assistant alternation, which is lossy for agent trajectories full of tool turns.
torchtune — wound down in 2025 and tombstoned in 2026. Do not start here. (Judge a repo by its default-branch commits;
pushed_atlies.)Unsloth — the fastest single-GPU LoRA engine, and fine underneath either of the first two.
The config that worked#
Qwen3.5-0.8B, LoRA, one H100. The keys that matter, with the reasons:
base_model: Qwen/Qwen3.5-0.8B
output_dir: /out # adapter lands here; the merge step reads it
chat_template: qwen3_5
freeze_mm_modules: true # Qwen3.5 is a unified VLM; freeze the vision tower
adapter: lora
lora_r: 16
lora_alpha: 32
lora_dropout: 0.0
lora_target_modules: # all projections, listed explicitly:
- q_proj # lora_target_linear: true would sweep the
- k_proj # vision tower's linears in too
- v_proj
- o_proj
- gate_proj
- up_proj
- down_proj
datasets:
- path: /data/train.jsonl
ds_type: json
type: chat_template
field_messages: messages
field_tools: tools
roles_to_train: ["assistant"]
train_on_eos: turn
test_datasets: # a real time-based holdout, not a random slice
- path: /data/holdout.jsonl
ds_type: json
split: train
type: chat_template
field_messages: messages
field_tools: tools
roles_to_train: ["assistant"]
train_on_eos: turn
val_set_size: 0 # required when test_datasets is set
sequence_len: 16384 # 8k conversation budget + ~8k gptme system prompt
sample_packing: true
eval_sample_packing: false
pad_to_sequence_len: true
bf16: true
attn_implementation: flash_attention_2
gradient_checkpointing: true
num_epochs: 1
micro_batch_size: 1 # REQUIRED with packing on Qwen3.5 (see below)
gradient_accumulation_steps: 16
learning_rate: 2e-4
lr_scheduler: cosine
warmup_ratio: 0.03
optimizer: adamw_torch_fused
The non-obvious ones, three of which cost real money to discover:
micro_batch_size: 1is mandatory withsample_packing: true+flash_attention_2on Qwen3.5. Anything else dies with “The batch size is expected to be 1 rather than N when usingcu_seqlens”. Packing already fills each 16k sequence; scale with gradient accumulation instead.freeze_mm_modules: truebecause Qwen3.5 loads as a multimodal model even for text-only training. Without it you also needddp_find_unused_parameters.Do not enable the LoRA kernels (
lora_mlp_kerneland friends). Their supported base list stops at gemma3; qwen3 and qwen3_5 are not on it.QLoRA is not worth it here — a 0.8–4B bf16 LoRA fits comfortably on one 80 GB card.
Measured numbers#
q08b-markdown-v5 — Qwen3.5-0.8B, LoRA r16, 3,080 sessions rendered in the
markdown format, ~33M tokens, 1 epoch, one H100 SXM on RunPod:
Metric |
Value |
|---|---|
Training wall clock |
1,170 s (19.5 min) |
Cost, all-in incl. setup, eval, upload |
~$3 |
Train loss |
0.70 |
Held-out eval loss |
1.918 → 1.721 (ppl 6.81 → 5.59) |
Trainable token fraction |
~9% |
The paired xml and tool adapters on the same 3,080 sessions took 609 s and
1,465 s and reached eval loss 1.656 and 1.722. Do not compare loss across
formats — the token mixes differ. gptme-eval is the comparison.
Public artifacts for q08b-markdown-v5 (adapter weights plus the generated
model card that embeds the Axolotl config):
https://s3.bob.gptme.org/training/runs/q08b-markdown-v5/README.md
It took five paid attempts (~$5 of debugging) to get one clean run, and every
single failure was plumbing, not learning: a missing tmux in the training
image, the HF-datasets meta cast, the packing/micro-batch constraint above,
an archive format the object-store client could not unpack, and a wall-clock
cap that killed a run at step 33 of 71. Budget the first $5 as integration
testing.
Model choice#
Model |
Why / why not |
|---|---|
Qwen3.5-0.8B |
The smallest rung and what was tested here. Weak in absolute terms — useful because the deltas are cheap to measure. There is no 1B in Qwen3.5; 0.8B is the floor. |
Qwen3.5-2B |
Next rung up, same config. |
Qwen3.5-4B |
The reference pick. Apache-2.0, ungated, native |
SmolLM3-3B |
The no-drama fallback: dense, text-only, and it ships native |
Qwen3-4B-Instruct-2507 |
Dense text-only, and the well-trodden vLLM-colocate path if you go on to RL/GRPO. Also the escape hatch if the unified-VLM path fights you: swap the base and set |
Serving and evaluating#
Serve with vLLM#
Axolotl’s output_dir with adapter: lora contains only
adapter_config.json + adapter_model.safetensors. vLLM serves full
checkpoints, so merge the adapter into the base first. Then:
vllm serve /path/to/merged-checkpoint \
--served-model-name tool-format-experiment \
--enable-auto-tool-choice \
--tool-call-parser qwen3_xml \
--structured-outputs-config.backend xgrammar \
--default-chat-template-kwargs '{"enable_thinking": false}' \
--chat-template /path/to/qwen3_5_midsystem.jinja \
--max-model-len 32768 \
--port 8000
--enable-auto-tool-choiceis mandatory for thetoolformat.--tool-call-parser:qwen3_xmlfor Qwen3.5 and Qwen3-Coder (XML-style<function=…>template),hermesfor plain Qwen3 instruct/thinking. Verify against the checkpoint’s owntokenizer_config.jsonrather than guessing from the name.--default-chat-template-kwargs '{"enable_thinking": false}': Qwen3.5 thinks by default. In the first eval attempt every generation ended atAssistant: Thinking...— the per-task timeout expired inside the think block — and both arms scored 0/116. Turn thinking off at serve time (or raise the timeout a lot).--chat-templatein the command above is the 2026-09-08/09 serve path (see the next section). On gptme with #3780, drop it and fold on the client instead.
The mid-conversation system message gotcha#
Qwen3.5’s stock chat template raises System message must be at the beginning. gptme injects context as non-leading system messages
(tool results go out as tool and are not the problem). On the
2026-09-08/09 run this burned two entire evals (580/580 requests failed,
both arms 0/116). It reads like a broken model or a timeout; only the
vLLM server log gives it away.
Fixed in gptme as of #3780
(merged 2026-09-09, closes
#3779). _fold_mid_system in
gptme/llm/llm_openai.py keeps the first system message and re-emits
every later system message without a call_id as a separate user
message wrapped in <system>…</system> — the same transform _prep_o1
already used for the o-series. It activates when the model id contains
qwen3.5 / qwen3_5, or when GPTME_FOLD_SYSTEM_MESSAGES=1. Tool-result
messages (system + call_id) stay for _handle_tools to convert to
tool.
This recipe’s --served-model-name values (tool-format-experiment,
sft) do not contain qwen3.5, so name inference will not fire.
On a gptme that includes #3780, either put qwen3.5 in the served name
(--served-model-name qwen3.5-sft, then gptme-eval --model local/qwen3.5-sft@markdown) or export GPTME_FOLD_SYSTEM_MESSAGES=1 for
the eval process.
The 2026-09-08/09 numbers were collected before #3780, by serving
with a copy of the chat template that renders non-leading system
messages as ordinary ChatML system turns instead of raising. That is
also the shape the SFT data was rendered in, so training and those
eval numbers agree. The client-side fold is a different transform
(role becomes user with a <system> wrapper). To reproduce the
table below, keep the template copy; to eval a Qwen3.5 on current
gptme, use the fold.
The template patch, for the historical serve path. In the message loop,
replace the raise_exception branch with:
{%- if message.role == "system" %}
{%- if not loop.first %}
{{- '<|im_start|>system\n' + content + '<|im_end|>' + '\n' }}
{%- endif %}
{%- endif %}
Copy the checkpoint’s own chat_template (from tokenizer_config.json)
and apply that replacement; the rest of the template stays stock.
Run gptme-eval#
gptme-eval reaches a self-hosted server through the local provider. The model
spec carries the tool format after an @, and it wins over --tool-format:
export OPENAI_BASE_URL=http://localhost:8000/v1
export OPENAI_API_KEY=vllm # required, ignored by a keyless vLLM
export EVAL_RESULTS_DIR=~/evals/my-run
gptme-eval --model local/tool-format-experiment@markdown \
--parallel 8 --timeout 600 basic all-practical
basic(18 tests) +all-practical(98) = 116 tests on this repo as of 2026-09-09, which is the cheapest suite pair that still discriminates. Recount fromgptme.eval.suitesif you need the denominator to match a later revision. See Evals for the full suite list.Repeat with
@xmland@toolfor the other formats. With no@suffix, gptme-eval runs the model once per format.--timeoutis the generation budget per eval, and the default is far too tight for a small model doing 10–15 agent turns. 600 s is generous; 60 s produced a wall of zeros.gptme-eval does not sandbox: each test runs its
runcommand with/bin/bash -cin the agent’s workspace, inheriting the environment. The box therefore needsbash,git, coreutils,python3, andpytest+mypy+requests+pytest-mockin the interpreter the tasks resolve, plus a global git identity. Get any of that wrong and you get uniformly failing tests that read like a bad model. Installgptme, notgptme[eval]— that extra is for the external benchmark lanes and pulls gigabytes these suites never touch.Don’t put
init_projects(needs cargo),browser/computer(need Playwright + Chromium) orsubagent(nested LLM calls) in the suite list unless you have provisioned for them.
Grammar-constrained decoding (experimental)#
The tool format gets constrained decoding for free — schemas go to the
server, the server constrains the arguments. markdown and xml get nothing;
they are parsed heuristically out of free text. A self-hosted vLLM can
constrain those too, given a context-free grammar for the fenced-block and
<tool-use> shapes.
gptme does not ship a grammar passthrough for markdown/xml. The
grammar columns in Results are from a local experiment that sent GBNF as
structured_outputs.grammar on self-hosted vLLM only — they are not part
of this recipe. Skip that arm unless you have your own wiring. If you do:
Send GBNF-flavoured EBNF with a rule named
root. vLLM hands the string toxgrammar.Grammar.from_ebnf; its Lark detection is a one-line heuristic that routes non-::=grammars through a lossy converter.structured_outputs.grammarneeds vLLM ≥ 0.11.0. The legacyguided_grammarfield was removed in 0.12.0 and is silently ignored since — so a stale client returns unconstrained text with no error. Probe it: constrain a request to the single literalGRAMMAR_OKand refuse to run if the answer differs.Pin
--structured-outputs-config.backend xgrammarso an invalid grammar fails loudly instead of quietly falling back to another engine.
Results#
Base Qwen3.5-0.8B versus the markdown-trained adapter, 116 tasks
(basic + all-practical), thinking off, one A40 for ~4 h (~$2):
format |
base, free |
base, grammar |
SFT, free |
SFT, grammar |
|---|---|---|---|---|
|
12/116 (10%) |
13/116 (11%) |
22/116 (19%) |
19/116 (16%) |
|
9/116 (8%) |
12/116 (10%) |
5/116 (4%) |
4/116 (3%) |
|
17/116 (15%) |
n/a |
26/116 (22%) |
n/a |
tool + grammar is n/a by construction: gptme skips the grammar for the
native format because the provider already constrains against the tool schemas,
so that cell would be a byte-for-byte rerun.
What this says, honestly:
Format SFT works. markdown 10% → 19% from one epoch of your own sessions rendered in that format. That is the headline.
It transfers to native tool calling (15% → 22%). The adapter learned what to do with gptme’s tools, not just the fence syntax. That was not obvious in advance.
It hurts the untrained format. xml went 8% → 4%: the model now confidently emits fences, and in
xmlmode gptme does not parse markdown code blocks at all, so those calls are silently not executed (a documented weakness — see Tool Formats). Format training is format-specific. Train the format you intend to run.Grammar is not a substitute for training. It is a small help for the untrained model (+1 / +3 pp) and a small harm on the SFT model (−3 pp, within noise). Syntax was not the bottleneck; semantics were.
n = 116, so the binomial standard error is 3–4 pp. Treat ±4 pp as noise. The markdown and tool gains clear that bar; nothing else in the table does.
A 0.8B is weak in absolute terms. 19% is not a usable agent. The point of the smallest rung is that the deltas cost $2 to measure; scale the model once the pipeline works, not before.
Compute#
Rent bare GPUs by the hour. No managed fine-tuning API is needed for any of this, and the whole experiment above — three adapters, several evals, five failed plumbing attempts and one idle cluster — came to about $16.
Job |
Hardware |
Rate |
Wall |
Cost |
|---|---|---|---|---|
LoRA SFT, 3k sessions / ~33M tokens |
1× H100 |
~$2.89/h |
~20 min |
~$3 |
Full eval, 2 arms × 5 cells × 116 tests |
1× A40 |
~$0.49/h |
~3.5 h |
~$2 |
The eval is bound on agent turns, not GPU throughput, so do not pay for an H100 to run it — an A40 costs a quarter as much and finishes at the same time. If the budget tightens, cut tasks per cell before cutting cells: a missing cell kills a hypothesis, fewer tasks just widens the confidence interval.
A forgotten H100 costs about $70/day, which is more than this entire experiment. Orchestrate with SkyPilot, whose autostop daemon runs on the remote cluster and therefore fires even if your laptop dies — neither RunPod nor Vast has a native idle timeout of its own:
resources:
autostop:
idle_minutes: 15
down: true # terminate; `stop` keeps the disk and keeps billing
wait_for: none # the load-bearing part
max_hourly_cost: 3.6 # refuses to provision anything pricier
wait_for: none is what turns idle_minutes into a hard wall clock. The
default is jobs_and_ssh, which resets the timer while a job runs or an SSH
session is open — so sky launch --down -i 15 alone does not tear the box
down after your job, and an open terminal will keep an H100 alive indefinitely.
Set the cap explicitly, and note SkyPilot’s own documented exception: if errors
occur during provisioning or setup, the cluster is not torn down — precisely
the case where a forgotten box costs money. Run sky down -y from an
EXIT/INT/TERM trap, verify with sky status --refresh (without
--refresh SkyPilot answers from a local cache and will happily report a
cluster down while the provider bills it), and back all of it with a prepaid,
hard-capped account. That last one is the only cap that survives a wedged
daemon or a bug in your own launcher.
Reproduce#
The training, merge, serve, and eval commands below are what ran.
The exporter is not in this repository. Reimplement it from Data
above. The patched Qwen3.5 chat template is also not in-tree: it is
only needed to reproduce the 2026-09-08/09 numbers, or if you are on
a gptme without #3780. Make
it by copying the checkpoint’s chat_template and applying the six-line
replacement in
The mid-conversation system message gotcha. On current gptme, skip
the template and fold on the client (GPTME_FOLD_SYSTEM_MESSAGES=1,
or put qwen3.5 in --served-model-name). Nothing here needs a GPU
until step 3.
# 1. Export paired datasets yourself (see Data). Hold out the last two months;
# render markdown/xml/tool from the same sessions and split; omit per-row
# metadata so HF datasets can cast the columns. Write
# ~/data/sft/run.markdown.train.jsonl (and the xml/tool twins).
# 2. Validate before renting anything: this is where a bad `arguments` column
# or a null-then-float field fails, and it costs nothing on your own box.
python3 -c "
from datasets import load_dataset
d = load_dataset('json', data_files='$HOME/data/sft/run.markdown.train.jsonl')
print(d)"
# 3. Train (on the rented box; ~20 min on one H100 for ~33M tokens).
axolotl preprocess qwen3.5-0.8b-lora.yaml
axolotl train qwen3.5-0.8b-lora.yaml
# 4. Merge the adapter into the base (vLLM serves full checkpoints, and step 3
# produces only an adapter), then serve. --chat-template is the 2026-09-08/09
# path (stock Qwen3.5 400s every gptme request without it). On gptme with
# #3780, drop --chat-template and export GPTME_FOLD_SYSTEM_MESSAGES=1 (this
# served name does not contain qwen3.5, so name inference will not fire).
python3 -c "
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer
AutoPeftModelForCausalLM.from_pretrained('/out').merge_and_unload().save_pretrained('/merged')
AutoTokenizer.from_pretrained('Qwen/Qwen3.5-0.8B').save_pretrained('/merged')"
vllm serve /merged --served-model-name sft \
--enable-auto-tool-choice --tool-call-parser qwen3_xml \
--default-chat-template-kwargs '{"enable_thinking": false}' \
--chat-template ./qwen3_5_midsystem.jinja --port 8000
# 5. Score it — and score the *base* model the same way, or you have one number
# and no verdict.
OPENAI_BASE_URL=http://localhost:8000/v1 OPENAI_API_KEY=vllm \
gptme-eval --model local/sft@markdown --parallel 8 --timeout 600 \
basic all-practical
Step 5 is the whole point. A fine-tuned model’s score on its own is not a result; the base-versus-SFT diff is.
See also#
Tool Formats — what
markdown,xmlandtoolactually do, and the xml-ignores-fences weakness that shows up in the results tableEvals — the eval suites, the leaderboard, and per-model best formats
Axolotl — the training stack used here
SkyPilot — burst GPU orchestration with a remote-side autostop
scripts/train/collect.pyin this repo — the legacy chat-only collector