Fine-Tuning Tool-Calling LLMs: A Complete Guide Using XYZ-Aquila-SFT and Qwen3

Fine-tuning tool-calling LLMs: A complete guide using XYZ-Aquila-SFT and Qwen3 The XYZ-Aquila-SFT dataset on Hugging Face contains multi-turn tool-use trajectories. This tutorial…

By Vane August 15, 2026 5 min read
Fine-Tuning Tool-Calling LLMs: A Complete Guide Using XYZ-Aquila-SFT and Qwen3

Fine-tuning tool-calling LLMs: A complete guide using XYZ-Aquila-SFT and Qwen3

The XYZ-Aquila-SFT dataset on Hugging Face contains multi-turn tool-use trajectories. This tutorial implements an end-to-end supervised fine-tuning pipeline using Hugging Face Transformers, PyTorch, and PEFT to adapt the Qwen3-0.6B model for these tasks.

Configuration and dependencies

The workflow begins by setting configuration variables for the repository, language, and model ID. It then installs required packages for data handling and model training. The script checks for CUDA availability and BF16 support before streaming a sample from the dataset.

Example configuration parameters include a maximum sequence length of 2048 tokens and a learning rate of 1e-4. The code detects the available compute device and prints the PyTorch version to verify the environment.

import os, sys, subprocess
CFG = dict(
   REPO            = "XYZAILab/XYZ-Aquila-SFT",
   LANG            = "en",
   MODEL_ID        = "Qwen/Qwen3-0.6B",
   MAX_SEQ_LEN     = 2048,
   RUN_TRAINING    = True,
   MAX_STEPS       = 30,
   LR              = 1e-4,
   LORA_R          = 16,
   OUT_DIR         = "/content/aquila_out",
   SEED            = 0,
)
os.makedirs(CFG["OUT_DIR"], exist_ok=True)
def pip(*pkgs):
   subprocess.run([sys.executable, "-m", "pip", "install", "-q", "-U", *pkgs], check=False)
pip("datasets>=3.0.0", "transformers>=4.51.0", "peft>=0.13.0", "accelerate>=1.0.0")
import torch
import random
random.seed(CFG["SEED"]); torch.manual_seed(CFG["SEED"])
DEV = "cuda" if torch.cuda.is_available() else "cpu"
BF16 = DEV == "cuda" and torch.cuda.is_bf16_supported()
print(f"device={DEV}  bf16={BF16}  torch={torch.__version__}")

Dataset parsing and inspection

The script streams the dataset and inspects the schema of the first entry. It prints the question, answer, and the number of tool calls declared in the metadata. The code then checks the length of the trajectory and lists the role sequence of the first eight messages.

Developers must extract structured tool calls from the raw text. Standard regex fails on nested JSON objects inside arguments. The provided solution uses a custom JSON decoder that scans for objects safely.

def iter_json_objects(text: str, limit: int = 1):
   """Nesting-safe JSON scanner."""
   dec, i, n, out = json.JSONDecoder(), 0, len(text), []
   while i < n and len(out) < limit:
       while i < n and text[i] not in "{[":
           i += 1
       if i >= n:
           break
       try:
           obj, end = dec.raw_decode(text, i)
       except json.JSONDecodeError:
           i += 1
           continue
       out.append(obj); i = end
   return out

def parse_tool_calls(content: str) -> List[Dict[str, Any]]:
   calls = []
   for m in re.finditer(r"<tool_call>", content):
       got = iter_json_objects(content[m.end():], limit=1)
       if got:
           calls.append(got[0])
   return calls

The parser iterates through the trajectory messages. It extracts tool calls from assistant messages and counts reasoning blocks and tool responses. A dataclass named Trajectory stores the question, answer, message list, and parsed tools.

After parsing, the code verifies that the extracted tool call count matches the value declared in the dataset metadata. It then calculates corpus-level statistics, including the mean and median number of tool calls per trajectory.

@dataclass
class Trajectory:
   question: str
   answer: str
   declared_calls: int
   messages: List[Dict[str, str]]
   system_core: str = ""
   tools: List[Dict[str, Any]] = field(default_factory=list)
   calls: List[Dict[str, Any]] = field(default_factory=list)
   n_observations: int = 0
   n_think: int = 0

def parse_row(row: Dict[str, Any]) -> Trajectory:
   msgs = [{"role": m["role"], "content": m["content"]} for m in row["trajectory"]]
   t = Trajectory(row["question"], row["answer"], row["number of tool calls"], msgs)
   # ... logic to extract system core and tools ...
   for m in msgs:
       if m["role"] == "assistant":
           t.calls += parse_tool_calls(m["content"])
           t.n_think += len(THINK_RE.findall(m["content"]))
       else:
           t.n_observations += len(TOOL_RESP_RE.findall(m["content"]))
   return t

Corpus statistics and visualisation

The analysis phase generates statistics for tool calls, message depth, and character counts. It produces a histogram of tool calls per trajectory using a logarithmic scale. A bar chart displays the frequency of different tool names.

The script also calculates the distribution of argument keys used within specific tool functions. It identifies the top 10% of longest trajectories and reports what percentage of total characters they contain.

calls_per   = [len(t.calls) for t in TRAJ]
depth_per   = [t.depth for t in TRAJ]
chars_per   = [sum(len(m["content"]) for m in t.messages) for t in TRAJ]
name_freq   = Counter(n for t in TRAJ for n in t.tool_names)

fig, ax = plt.subplots(1, 3, figsize=(15, 3.6))
ax[0].hist(calls_per, bins=40); ax[0].set_yscale("log"); ax[0].set_title("tool calls / trajectory")
ax[1].hist(depth_per, bins=40); ax[1].set_yscale("log"); ax[1].set_title("messages / trajectory")
ax[2].bar(list(name_freq), list(name_freq.values())); ax[2].set_title("tool usage"); ax[2].tick_params(axis="x", rotation=20)
plt.tight_layout(); plt.show()

Converting schemas for Qwen3

The pipeline converts tool schemas between message-embedded and structured formats. It extracts the tools from the system message and re-embeds them into a Qwen-compatible ChatML template.

Developers must ensure the output format matches the model’s expectations. The code defines a template string that instructs the model to return JSON objects within specific XML tags.

QWEN3_TOOLS_TMPL = (
   "You are provided with function signatures within <tools></tools> XML tags:\n<tools>\n"
   "{lines}\n</tools>\n\nFor each function call, return a json object with function name "
   "and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n"
   '{{"name": <function-name>, "arguments": <args-json-object>}}\n</tool_call>'
)

def extract_tools(t: Trajectory) -> Dict[str, Any]:
   msgs = [dict(m) for m in t.messages]
   if msgs and msgs[0]["role"] == "system":
       msgs[0]["content"] = t.system_core
   return {"messages": msgs, "tools": t.tools,
           "question": t.question, "answer": t.answer}

def render_tools(rec: Dict[str, Any]) -> List[Dict[str, str]]:
   msgs = [dict(m) for m in rec["messages"]]
   if rec["tools"] and msgs and msgs[0]["role"] == "system":
       lines = "\n".join(json.dumps(x, ensure_ascii=False) for x in rec["tools"])
       msgs[0]["content"] = msgs[0]["content"] + QWEN3_TOOLS_TMPL.format(lines=lines)
   return msgs

The final step renders the tools back into the message list. The code compares the rendered output against the original system message to check for byte-level exactness.

Scroll to Top