Two teams set out to specialize the same 8B base for the same job: turning a messy support ticket into a resolved case, using three tools. Team one exports eleven thousand historical agent runs from the trace store, converts them wholesale to JSONL, and starts a training job on Tuesday morning. Team two spends the same week differently — they hand-write forty trajectories, argue for two days about eleven of them, generate three thousand more with a large teacher model, filter those down to nine hundred that pass a rubric, and train on that.
Team one's model reproduces every bad habit in eleven thousand runs: the six-tool-call flailing that eventually worked, the abandoned trajectories where the user gave up, the two months when the retriever was broken and the agent compensated by guessing. Team two's model behaves like their best engineer on their best day, because that is literally what it studied. Both teams ran the same trainer with the same hyperparameters. The dataset was the entire difference, and the dataset is almost always the entire difference.
This is the lesson where fine-tuning projects are actually won. You will build the canonical example format including tool calls, learn the four quality properties that decide a run before it starts, write a cleaning and deduplication pipeline, split without leaking, and version the result so that Lesson 6's training run is reproducible and Lesson 8's evaluation is honest. Lesson 4 then takes this machinery to production scale against your real trace store.
Modern instruction tuning uses the chat format: a list of role-tagged messages, one JSON object per line in a JSONL file. This is what TRL, Axolotl, Unsloth, and LLaMA-Factory all consume natively, and it is a superset of every older format you will meet. A simple single-turn example looks like this:
{"messages": [
{"role": "system", "content": "You triage support tickets."},
{"role": "user", "content": "Ticket #8841: checkout hangs on Pay since 14:00, EU customers, card never charged."},
{"role": "assistant", "content": "SEVERITY: high\nCOMPONENT: payments/checkout\nSUMMARY: Checkout hangs at payment submission for EU traffic since 14:00; no charges processed.\nACTION: escalate to payments on-call"}
]}
An agent example is the same structure with two more roles in play. The
assistant emits tool_calls instead of (or alongside) content,
and the environment answers with a tool message carrying the
result. A two-step trajectory:
{"messages": [
{"role": "system", "content": "You triage support tickets. Use tools before concluding."},
{"role": "user", "content": "Ticket #8841: checkout hangs on Pay since 14:00, EU customers."},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "c1", "type": "function",
"function": {"name": "search_incidents",
"arguments": "{\"component\": \"payments/checkout\", \"since\": \"2026-03-11T13:00Z\"}"}}]},
{"role": "tool", "tool_call_id": "c1",
"content": "{\"incidents\": [{\"id\": \"INC-4412\", \"status\": \"open\", \"title\": \"EU card auth timeouts\"}]}"},
{"role": "assistant", "content": "SEVERITY: high\nCOMPONENT: payments/checkout\nLINKED: INC-4412\nACTION: attach ticket to INC-4412, no new escalation"}
],
"tools": [{"type": "function",
"function": {"name": "search_incidents",
"description": "Find open incidents for a component since a timestamp.",
"parameters": {"type": "object",
"properties": {"component": {"type": "string"},
"since": {"type": "string"}},
"required": ["component"]}}}]}
Three properties of that example are load-bearing and each one is a classic source of ruined runs.
A chat template is a Jinja program shipped in the tokenizer config that turns your message list into the exact token sequence the model was post-trained on: turn delimiters, role markers, the tool-call serialization, the generation prompt. Qwen-family models use one dialect, Llama-family another, and their tool-call encodings differ in ways that are invisible in rendered text and fatal in tokens.
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
text = tok.apply_chat_template(
example["messages"],
tools=example["tools"], # rendered into the prompt by the template
tokenize=False,
add_generation_prompt=False, # False for training rows, True at inference
)
print(text[:600])
Print that string once, early, and read every character of it. You are looking for four things: the delimiters match what the model card documents, the tool schemas appear where the template says they appear, the tool results are wrapped in the model's tool-response markers rather than pasted as user text, and there is no stray whitespace or duplicated system block. Ten minutes here saves a training run.
| Property | What it means for agent data | Failure it prevents |
|---|---|---|
| Correct | Every assistant turn is genuinely what you want produced — the right tool, valid arguments, an answer that actually resolved the case | Mistakes taught with authority: one wrong tool choice, learned as a reflex |
| Consistent | Same situation, same convention — one severity vocabulary, one argument style, one place where the agent stops and asks | The model rolling dice between the three behaviors you accidentally taught |
| Diverse | Coverage of trajectory lengths, tool combinations, phrasings, and the ugly inputs production actually sends | Distribution lock-in: brilliant on clean tickets, lost on real ones |
| Complete at the edges | Failure recovery (the tool errored and the agent handled it), refusal and escalation (this needs a human), and the ambiguity escape hatch (ask one clarifying question) |
Running it keeps t1, t4, and nothing else:
t2 collapses onto t1 exactly once whitespace,
case, and punctuation are normalized, t3 is the same ticket with a
contradictory tool choice and severity, t5 fails the schema
gate on a lowercase field name, and t6 is t4
reworded. Retention of 33% on a hand-built six-row sample is
unrepresentative, but the direction is real: expect to drop 20-50% of a
raw trace export, and treat a pipeline that drops almost nothing as
evidence that a gate is broken rather than that your data is pristine.
Three-way split, made before you look too closely at anything: train (about 90%), validation (about 5%, which steers checkpoint selection during Lesson 6's run), and test (about 5%, touched once by Lesson 8's evaluation, after every decision is frozen).
Two rules carry all the weight. Deduplicate before splitting, or your test set is a memorization quiz. And split by natural group whenever one exists — the same customer, the same incident, the same document, the same seed scenario in a synthetic batch must land entirely on one side of the line. Random splitting on grouped data is the most common way teams convince themselves a mediocre fine-tune worked.
The random split leaks essentially 100% — every test ticket has a sibling from the same incident sitting in training, so the test score measures recall of near-copies. The grouped split leaks 0% and reports a number you can act on. The cost is that split sizes drift from your target ratio whenever groups differ in size, which is a price worth paying every time.
A dataset is a production artifact, not a file someone had on a laptop. The minimum viable discipline is a content hash, an immutable copy in object storage, and a short datasheet committed next to it. When Lesson 8 says the tuned model regressed on multi-tool cases, the first question is which dataset produced it, and the second is what changed since the last version — both of which are unanswerable without this.
import hashlib, json, pathlib
def write_split(rows, path):
body = "\n".join(json.dumps(r, sort_keys=True, ensure_ascii=False) for r in rows)
pathlib.Path(path).write_text(body + "\n", encoding="utf-8")
return hashlib.sha256(body.encode()).hexdigest()[:12]
manifest = {
"name": "ticket-triage-agent-sft",
"version": "v3",
"base_model": "Qwen/Qwen3-8B",
"splits": {k: write_split(v, "data/{}.jsonl".format(k)) for k, v in dataset.items()},
"sources": {"production_traces": 8412, "synthetic_teacher": 3100, "hand_written": 62},
"filters": ["schema", "exact-dup", "contradiction", "[email protected]", "judge>=4", "eval-decontam"],
"eval_suites_decontaminated_against": ["triage-v4", "tau-bench-retail"],
"notes": "v3 adds 900 error-recovery trajectories; v2 had none.",
}
print(json.dumps(manifest, indent=2))
The two fields people skip are the ones that matter most six months
later. filters tells the next engineer what was already
removed, so they do not spend a week rediscovering it.
eval_suites_decontaminated_against is the record that your
headline number is not measuring memorization — and if it is empty, no
score derived from this dataset should be quoted anywhere.
Run this before every training run. Each line is one of this lesson's failure stories, compressed:
| An agent with no trained move for an empty search result — so it invents one |
The fourth row deserves emphasis because it is where agent datasets differ most from chat datasets. If every trajectory in your training set is a clean success, you have taught the model that tools always work. In production they time out, return empty, return malformed JSON, and return the wrong thing confidently. A dataset with no error-recovery trajectories produces an agent that treats a 500 response as a surprise. Budget 10-15% of your examples for the unhappy paths, and make sure the demonstrated recovery is the one you want — retry once, then escalate, not retry eleven times.
Honest ranges rather than a magic number, because the answer scales with how much behavior you are changing:
When in doubt, start at the small end with high quality. You can always add data to a pipeline; you cannot easily subtract what a bad example taught, and diagnosing a dataset defect in a 40,000-row corpus costs more than building a clean 4,000-row one.
Real and synthetic data both arrive dirty. The pipeline below is the minimum standing between your corpus and three classic ruins: exact duplicates that silently overweight one pattern, near-duplicates that will leak across your splits in the next section, and contradictions — the same input paired with two different target behaviors, which trains the model to be inconsistent on purpose.