Fine-tuning Qwen to use its tools

Five minutes of LoRA training on 223 scripted examples takes Qwen3-0.6B from pass^1 0.025 to about 0.4 on the unchanged Day 7 eval. Valid tool calls go from a third to almost all. Two training seeds differ by more than the sampling seeds do, and tasks that need debugging stay unsolved.

The scripted traces show Qwen reading before it answers and writing valid JSON. Now we train on them with LoRA and rerun the Day 7 eval with nothing changed. It uses the same 40 tasks, the same three sampling seeds and the same limits.

Setup

Settings frozen before the run

The plan fixed every setting before the first step, starting from QLoRA's 7B recipe:

SettingValueWhy
Rank and α16 and 32QLoRA found rank barely matters once every layer is adapted
Learning rate0.0002, constantQLoRA's 7B rate. LoRA wants about ten times the full fine-tuning rate
Examples per optimizer step8LoRA loses more than full fine-tuning as the batch grows
Epochs3223 examples, so 84 optimizer steps
Max gradient norm0.3QLoRA's setting
OptimizerAdamWbetas (0.9, 0.999), no weight decay
Precisionfloat16 autocastThe T4 has no bfloat16, so a GradScaler guards against underflow

Two adapters train with seeds 0 and 1. The seed sets A's initialization and the example order. Comparing the two shows how much of the result is luck.

octlm/day8.pyline 183
def stage_train(args: argparse.Namespace) -> None:
    device = resolve_device(args.device)
    model, manifest = load_qwen(args.model, str(device), dtype_for(device))
    tokenizer = QwenTokenizer(args.model)
    data = [row for _, messages in traces(args.tasks) for row in examples(messages, tokenizer)]
    longest = max(data, key=lambda row: len(row[0]))[0]
    _write(args.out, step_zero(model, longest, device, args.seed))
    trainable = [p for p in model.parameters() if p.requires_grad]
    optimizer = torch.optim.AdamW(trainable, lr=LEARNING_RATE, weight_decay=0.0)
    scaler = torch.amp.GradScaler(device.type, enabled=device.type == "cuda")
    generator = torch.Generator().manual_seed(args.seed)
    if device.type == "cuda":
        torch.cuda.reset_peak_memory_stats(device)
    model.train()
    started, step = time.perf_counter(), 0
    for epoch in range(EPOCHS):
        order = torch.randperm(len(data), generator=generator).tolist()
        for first in range(0, len(order), BATCH):
            batch = [data[i] for i in order[first : first + BATCH]]
            total = sum(len(ids) - start for ids, start in batch)
            loss = 0.0
            for ids, start in batch:
                value = example_loss(model, ids, start, total, device)
                scaler.scale(value).backward()
                loss += value.item()
            scaler.unscale_(optimizer)
            norm = torch.nn.utils.clip_grad_norm_(trainable, MAX_GRAD_NORM).item()
            scaler.step(optimizer)
            scaler.update()
            optimizer.zero_grad(set_to_none=True)
            step += 1
            row = {"type": "sft_step", "step": step, "epoch": epoch, "loss": loss}
            _write(args.out, row | {"grad_norm": norm, "target_tokens": total})
    seconds = time.perf_counter() - started
    adapters = {k: v.detach().cpu() for k, v in model.state_dict().items() if ".lora_" in k}
    torch.save(
        {
            "adapters": adapters,
            "rank": RANK,
            "alpha": ALPHA,
            "seed": args.seed,
            "manifest": manifest,
            "tokenizer_hash": tokenizer.fingerprint,
            "tasks_hash": file_hash(args.tasks),
        },
        args.adapter,
    )
    _write(
        args.out,
        {
            "type": "sft_summary",
            "seed": args.seed,
            "examples": len(data),
            "steps": step,
            "seconds": seconds,
            "tokens_per_second": EPOCHS * sum(len(ids) for ids, _ in data) / seconds,
            "peak_memory_bytes": torch.cuda.max_memory_allocated(device)
            if device.type == "cuda"
            else None,
            "adapter_hash": file_hash(args.adapter),
            "settings": [RANK, ALPHA, LEARNING_RATE, BATCH, EPOCHS, MAX_GRAD_NORM],
        },
    )
Training

The loss goes to almost zero

Loss
Loss axis
seed 0seed 1
9.8e−43.9e−31.6e−26.3e−22.5e−11.0e+0020406080optimizer steploss on target tokensseed 0seed 1
Hover the chart to read values.

Seed 0 starts at 1.75 and ends at 6.0e-4. Seed 1 starts at 1.24 and ends at 1.6e-2. The log axis shows the drop continuing through the third epoch, long after the linear axis looks flat.

Loss on target tokens at every optimizer step, both seeds. Each point is one batch of 8 examples, so it jumps with which examples landed in the batch.

Losses this low on 7,287 target tokens mean the adapters memorized the traces. There's no held-out loss to say otherwise, because the eval is the held-out test. Each run took about 268 seconds at 1,777 tokens per second.

Result

pass^1 goes from 0.025 to 0.421

MeasureDay 7 baseSeed 0Seed 1
pass^1 mean0.0250.3750.467
pass^1 per sampling seed0.050, 0.025, 0.0000.350, 0.375, 0.4000.450, 0.475, 0.475
pass^30.0000.3500.450
Valid-call rate0.3330.9760.985
Success, answer0.0240.3330.595
Success, file0.0740.8150.704
Success, hidden0.0000.2500.500
Success, tests0.0000.1110.000
Mean turns1.184.625.06
Runs stopped by the turn limit22646
Generated tokens per second18.313.914.1

The exit check, written before the run, asks whether the gain beats the spread between training seeds. The mean of the two adapters is 0.421. Minus the base 0.025, that's a gain of 0.396, against a gap of 0.092 between the adapters. The check passes by a factor of 4.

Every run
Model
answerfilehiddentestsseed 0seed 1seed 2
passedcalled a tool, failedno tool calldifferent result from the reference
Runs passed45 of 120pass^1 0.375
Runs with a tool call120 of 120Day 7 base: 10
Picked runpassed2 turns, 1 of 1 calls valid

low-stock-file (answer): Which file defines the function low_stock? Reply with the answer.
Final answer: "shop/inventory.py"

One cell per run, as in the Day 7 eval. Switch between the base model and each adapter. Dashed cells changed result against the base model, or against seed 0 when you view the base.

Both Day 7 failures are gone

Day 7 found two problems. The model answered without calling a tool, and its write_file JSON broke. The valid-call rate rose from 0.333 to above 0.976. pass^1 counting only runs with a valid call is 0.375 and 0.467, equal to plain pass^1, so every pass came from a run that used a tool. The lucky guesses of the base model are gone.

Debugging is still out of reach

tests tasks plant a bug and ask the model to fix it. The adapters passed 3 of 54 such runs. Seed 1 hit the turn limit in 46 runs, including all 27 of its tests runs. It loops on reading files and running tests instead of writing a fix. The traces showed the right sequence, run the tests, read, fix, run again, but 14 examples of a bug fix aren't enough to teach what the fix should be.

The training seed matters more than the sampling seed

Within one adapter, the three sampling seeds span 0.050 for seed 0 and 0.025 for seed 1. Between the adapters, pass^1 moves by 0.092. Two adapters trained on the same data with the same settings behave differently, and the per-kind rows show it: seed 1 is better at answer and hidden, and seed 0 is better at file. One training run would have hidden that.

Decision: keep. The fine-tune passes the exit check, and both Day 7 failure modes are gone.

Skipped

Not built