> ## Documentation Index
> Fetch the complete documentation index at: https://docs.baseten.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Fine-tune tool calling with SFT

> Fine-tune Qwen3.6-35B-A3B on real tool-agent conversations with Loops, then compare the adapter with the base model on held-out conversations.

This example fine-tunes [Qwen3.6-35B-A3B](https://docs.baseten.co/loops/supported-models.md) with supervised fine-tuning (SFT) on 2,000 tool-use conversations from [Agent-Ark/Toucan-1.5M](https://huggingface.co/datasets/Agent-Ark/Toucan-1.5M). The conversations use real MCP servers and include parallel calls, multi-step chains, multi-turn dialogues, and requests no offered tool can serve. It then samples the base model and the trained adapter on 200 held-out conversations and scores whether each called the right tools with the right arguments.

It follows [Prepare data for supervised fine-tuning](https://docs.baseten.co/loops/sft-data.md) and [Evaluate a model](https://docs.baseten.co/post-training/evaluate.md) on a public dataset, using the Loops cookbook.

## Prerequisites

* A Baseten workspace with Loops and its [API key](https://docs.baseten.co/organization/api-keys.md).
* [uv](https://docs.astral.sh/uv/) and Python 3.12 or later.

## Install the cookbook

Install the [Loops cookbook](https://pypi.org/project/loops-cookbook/) from PyPI into a [uv](https://docs.astral.sh/uv/) project. The cookbook needs Python 3.12 or later, installs the [`baseten-loops`](https://pypi.org/project/baseten-loops/) SDK, and provides the `loops` command. `loops` runs both recipes; each training config names its recipe with `recipe = "rl"` or `recipe = "sft"`. Create a project first if you don't have one:

```sh theme={"system"}
uv init --python 3.12 loops-project
cd loops-project
uv add loops-cookbook
```

The package includes the cookbook's examples. Run one using its bundled config path, such as `examples/counting/train.toml`, from your project's directory. To change an example, copy its directory into your project at the same path. A config in your project wins over the installed one, and `loops check` prints the directory of the config it used.

Create a `.env` file in your project's directory and set `BASETEN_API_KEY` to a [workspace API key](https://docs.baseten.co/organization/api-keys.md). The commands load `.env` from the training config's directory or the directory you run them from. Never commit `.env`.

A run uses the serverless [availability mode](https://docs.baseten.co/loops/availability-modes/overview.md) when your workspace has serverless access to the model, and the dedicated mode otherwise. To choose a mode, add `BASETEN_LOOPS_SCOPE=serverless` or `BASETEN_LOOPS_SCOPE=deployment` to `.env`.

## Build the dataset

`examples/sft_toucan/make_data.py` downloads part of Toucan and rewrites each conversation into the chat JSONL format described in [Format conversations as chat JSONL](https://docs.baseten.co/loops/sft-data.md#format-conversations-as-chat-jsonl). It splits by tool set, so the validation conversations use tool combinations the training data never saw. That keeps near-duplicate conversations from landing on both sides of the split.

`make_data.py` writes the dataset beside itself, and `evaluate.py` writes its results there too, so copy the example into your project first. To copy it, run the following command:

```sh theme={"system"}
mkdir -p examples
COOKBOOK_DIR=$(uv run python -c 'import loops_cookbook; print(loops_cookbook.__path__[0])')
cp -R "$COOKBOOK_DIR/bundled/examples/sft_toucan" examples/
```

To build the training and validation files, run the following command from your project's directory:

```sh theme={"system"}
uv run --with pyarrow python examples/sft_toucan/make_data.py
```

The script reports the split and every row it left out, with the reason:

```text wrap theme={"system"}
train: 2000 conversations, {'single-turn-diversify': 300, 'multi-turn': 700, 'single-turn-original': 700, 'irrelevant': 300}
validation: 200 conversations, {'irrelevant': 30, 'single-turn-diversify': 30, 'single-turn-original': 70, 'multi-turn': 70}
left out (of the rows read before each subset filled):
    1091  over 3584 tokens
      36  tool call arguments are not JSON
       2  tool call without a name
       1  does not end on an assistant turn
```

The `irrelevant` conversations are requests no offered tool can serve, where the right answer calls no tool. They're 15% of each split, so the model learns when not to call a tool as well as when to call one.

## Check what trains

`examples/sft_toucan/sft.toml` sets the model, data, and schedule:

```toml examples/sft_toucan/sft.toml theme={"system"}
recipe = "sft"

[model]
base_model = "Qwen/Qwen3.6-35B-A3B"
lora_rank = 32
max_seq_len = 4096
# Toucan has no reasoning; see "Choosing settings" in docs/reference/sft.md.
renderer_chat_template_kwargs = { enable_thinking = false }

[data]
train = "data/train.jsonl"
validation = "data/validation.jsonl"

[training]
learning_rate = 2e-4
lr_schedule = "cosine"
warmup_steps = 10
batch_size = 16
epochs = 1

[eval]
every_steps = 10

[output]
run_name = "sft-toucan"
trainer_checkpoint_every = 25
sampler_checkpoint_every = 0
```

Toucan has no reasoning, so thinking is off. With it on, Qwen3.6 would learn to write an empty reasoning block before every answer.

To render every conversation with Qwen3.6's chat template and report what trains, run the following command. It launches nothing:

```sh theme={"system"}
uv run loops check examples/sft_toucan/sft.toml
```

The report starts with the files the check used. Confirm that `Config:` names your copy in `examples/sft_toucan/`, not the installed one. The rest reports what trains:

```text wrap theme={"system"}
Renderer: Qwen36Renderer
train: 2000 rows -> 2000 sequences, 1171026 of 3447520 tokens trained (34.0%); tokens per sequence median 1618.5, p95 3281, max 3582
validation: 200 rows -> 200 sequences, 118908 of 330915 tokens trained (35.9%); tokens per sequence median 1623, p95 3099, max 3550
Schedule: 125 update(s), 125 per epoch of up to 16 sequences; learning rate 0.0002, cosine after 10 warmup update(s)
Trained text of train.jsonl:1, span by span:
  ...
Configuration and data checked; no Loops trainer launched.
```

About a third of the tokens train: the assistant turns. The longest sequence, 3,582 tokens, fits within `max_seq_len = 4096`. The trained spans of the first row end with `<|im_end|>`, so each turn learns to stop.

## Train

To train for one epoch, run the following command. The run uses the serverless [availability mode](https://docs.baseten.co/loops/availability-modes/overview.md), billed for the tokens you train, when your workspace has serverless access to Qwen3.6-35B-A3B, and the dedicated mode, billed for GPU time, otherwise.

```sh theme={"system"}
uv run loops run examples/sft_toucan/sft.toml
```

The run scores validation loss before the first update and every 10 updates. The run's log prints the Loops run ID and the final checkpoint, and the run directory's `run.json` holds the run ID. The last update's sampler weights are always kept, as `step-<N>`.

The cookbook's recorded run of this config took 125 updates, about 12 minutes of training once the trainer was up. `val/nll` fell from 0.914 before the first update to 0.795, mostly in the first 20 updates, and was flat over the last 20.

## Compare the adapter with the base model

Validation loss shows the adapter fits the data. To check that it does the task better, sample both models on the validation conversations. `examples/sft_toucan/evaluate.py` takes each conversation's first assistant turn, samples it greedily from the base model and from the adapter, and scores four checks against the data:

* `decision`: calls a tool exactly when the data does.
* `tools`: calls the same tools.
* `arguments`: calls the same tools with the same arguments.
* `well_formed`: stops, and every call parses.

To run it, pass the adapter's checkpoint path, with the run ID and final step from the training run:

```sh theme={"system"}
uv run python examples/sft_toucan/evaluate.py \
  --adapter "bt://loops:<run_id>/sampler_weights/step-<N>"
```

The script starts a sampler for each model and deactivates both when it finishes. On the 200 validation conversations, the recorded run scored:

| Check | Base model | Adapter |
| - | - | - |
| `decision` | 88.0% | 99.0% |
| `decision` on the 30 `irrelevant` conversations | 73.3% | 100% |
| `tools` | 81.8% | 88.8% |
| `arguments` | 58.8% | 63.5% |
| `well_formed` | 91.0% | 98.5% |

The data's own replies score 98.5% `well_formed` under Qwen3.6's parser, so the adapter reached that ceiling. The largest change is on requests no tool can serve: the base model reaches for an unrelated tool anyway, and the adapter answers directly.

`evaluate.py` measures agreement with the data on first turns. It doesn't run tools or score later turns. Before you ship a model trained this way, build an evaluation that covers your own tools and the rules your task needs. See [Evaluate a model](https://docs.baseten.co/post-training/evaluate.md).

## Shut down the run

`loops run` leaves the Loops run active. On serverless, deactivation is optional because idle runs incur no token charges. On dedicated capacity, [deactivate the run](https://docs.baseten.co/loops/deactivate-a-run.md) when you finish to stop GPU billing without waiting for automatic shutdown. To end the run and its paired sampler in either mode, run the following command with your run directory:

```sh theme={"system"}
uv run loops deactivate "runs/<run directory>"
```

The command prints the deactivated run ID. Saved checkpoints remain available.

## Next steps

* [Deploy a checkpoint](https://docs.baseten.co/loops/deploy-checkpoints.md): Serve the adapter as an OpenAI-compatible endpoint.
* [Prepare data for supervised fine-tuning](https://docs.baseten.co/loops/sft-data.md): Build a dataset from your own traces.
* [Train a tool-using agent with RL](https://docs.baseten.co/examples/rl-tool-agent.md): Train on outcomes instead of fixed answers.
