Multi-Turn Agent (Tool-Calling) SFT with NeMo AutoModel
Multi-Turn Agent (Tool-Calling) SFT with NeMo AutoModel
This guide explains how to fine-tune Qwen2.5-3B for multi-turn agentic tool use with NeMo AutoModel. The model receives tool definitions and a conversation that interleaves tool calls and responses. It learns to emit correct tool_calls (tool name and arguments) and use the returned results across several turns.
This is the multi-turn counterpart to the single-turn Function Calling with FunctionGemma guide. That guide maps one user query to one set of tool calls using make_xlam_dataset. This guide handles full multi-turn agent traces (user → tool_call → tool response → assistant → ...) using make_agent_chat_dataset.
Multi-Turn Agent SFT
A multi-turn agent trace contains the following elements in order:
- A user request
- One or more assistant turns that might emit
tool_calls(with parallel calls in a single turn) - Tool responses paired back to those calls
- A final assistant answer that uses the tool results
The training data is a set of such traces plus the tool schema available to the model. The dataset adapter renders each trace through the tokenizer’s chat template with answer_only_loss_mask=True. Only assistant and tool-call tokens contribute to the loss. The adapter masks user and tool tokens.
Define the Fine-Tuning Task
You fine-tune on the llamafactory/glaive_toolcall_en dataset, which contains ShareGPT function-calling traces.
The following table compares the target behavior with the base model’s behavior:
Generate Coding-Agent Traces on NeMo Repositories
The julien-c/synthtraces generator pairs a Pi coding agent with a local user model. The coding agent inspects a checked-out repository with read and bash, while the user model asks follow-up questions. Pi writes each completed session as a trace under dataset/sessions.
To generate NeMo-specific traces, fork that generator and configure its repository matrix for these repositories:
The upstream generator lists bare repository names in src/constants.ts (TOP_HF_REPOS) and hard-codes the owner when it builds the clone URL in src/pull.ts, which resolves every entry against https://github.com/huggingface/<name>.git. Because the NeMo repositories live under different owners, change both locations in your fork so each matrix entry carries a complete owner and repository pair and pull.ts uses that owner instead of the fixed one. Keep the generated Pi session files unchanged. Hugging Face converts those trace files into the OpenAI-format messages rows consumed by make_agent_chat_dataset.
After configuring the matrix, install and prepare the generator:
The coding agent runs on the Hugging Face router, so export a token before generating. src/single-session.ts raises HF_TOKEN is not set without it:
The user model runs locally under llama.cpp. Serve the first entry of LOCAL_MODELS on the port the generator expects:
src/constants.ts reads LLAMA_BASE_URL and falls back to http://localhost:8080/v1, so set that variable if you serve the model elsewhere. src/generate.ts always picks LOCAL_MODELS[0] as the user model, so serve that entry rather than another one from the list.
Serving the user model with vLLM or SGLang. llama.cpp is not a requirement. src/single-session.ts registers the user model as a generic OpenAI provider (api: "openai-completions") whose base URL is LLAMA_BASE_URL, so any OpenAI-compatible server works and no generator code has to change:
Disable thinking on the server, as shown above. The generator registers the user model with reasoning: false and opens its session with thinkingLevel: "off", so it never sends the control that would turn thinking off at inference time. The packaged GGUF build answers without thinking anyway, which is why llama.cpp needs no flag, but a stock Qwen checkpoint served directly has it enabled by default. Leave it on and every generated user turn arrives wrapped in </think> and runs to thousands of characters instead of a short question, which lands in the persisted trace and then in the training data. The thinking block also stays in the message content rather than in a separate reasoning field, because the provider registration declares no reasoning support and the server parses none, so lastAssistantText() picks it up along with the question. On SGLang, disable thinking through the equivalent server-side option. As an alternative to the server flag, register the user model with Qwen chat-template compatibility (reasoning: true plus compat.thinkingFormat: "qwen-chat-template") and keep the session thinking level off.
Two things to keep aligned. LOCAL_MODELS ships GGUF repository ids, which vLLM supports only partially and SGLang does not serve, so replace LOCAL_MODELS[0] with the unquantized repository you actually serve. The generator sends that entry as the model id, so the served model name has to match it exactly. The placeholder apiKey: "no-key" in the provider registration is harmless, because neither server requires a key by default.
Make provider failures fail the run. runSession awaits each prompt() without inspecting the
result, so a provider error (an exhausted quota, a rate limit, an HTTP 402 or 429) produces an
assistant message whose stopReason is error, and the loop moves on. The process still exits 0,
still prints Trace saved to ..., and the batch path still counts the session as complete, so a
truncated conversation reaches the dataset looking like a finished one. Add the check in your fork,
next to the matrix changes above:
Call it after each prompt() in the turn loop. The run then exits non-zero on the first failed
turn instead of printing Trace saved to ..., so the batch path cannot count it as complete. Pi
streams the session file as the conversation goes, so a partial file still lands on disk and
should be discarded rather than uploaded.
With the server up, generate a small validation batch before scaling up:
Upload the generated dataset with the current Hugging Face CLI:
Train on the Generated Traces
Use the ready-made config at examples/llm_finetune/agent/qwen2_5_3b_synthtraces.yaml. It reads julien-c/synthtraces through refs/convert/parquet and renders traces with qwen2_5_synthtraces_chat_template.jinja:
To train on your own generated dataset instead, override dataset.dataset_name and validation_dataset.dataset_name. The dataset must expose parsed OpenAI-format rows through its normal dataset revision or through refs/convert/parquet.
A short run of this recipe produces training output in this shape:
This recipe reports loss and val_loss only. It declares no tool_call_eval block, so none of the tool_call/* metrics described in Monitor Training appear in its logs. Those metrics belong to the glaive_toolcall walkthrough below, which supplies the evaluator and the tool schema it scores against.
The SynthTraces chat template renders assistant reasoning_content as <think> inside its {% generation %} block, and mask_reasoning_content defaults to false. Reasoning tokens therefore contribute to the loss, and the fine-tuned model learns to emit <think> blocks. In the published julien-c/synthtraces training split, roughly two-thirds of the traces carry reasoning content, so this is the common case rather than an edge case. Set dataset.mask_reasoning_content: true to keep reasoning in the prompt but out of the loss, or dataset.drop_history_reasoning_content: true to strip prior-turn reasoning from the prompt entirely.
Trace generation is intentionally maintained by the SynthTraces project. NeMo AutoModel consumes the generated traces and does not vendor the Pi runtime, llama.cpp server, or remote inference provider.
Hardware Requirements
Review the following hardware requirements:
- Full-parameter SFT: Both recipes shard across 8 GPUs with FSDP2. Qwen2.5-3B can also train unsharded on a single 80 GB GPU because FSDP2 applies only across multiple GPUs.
- PEFT with LoRA: You can train on a single GPU. See Use PEFT with LoRA.
- Dataset size: Both datasets hold a few thousand traces, so a full pass is fast. SynthTraces traces are long multi-turn coding sessions, so its recipe uses
seq_length: 8192against4096forglaive_toolcall_en.
The rest of this guide walks through the glaive_toolcall_en recipe, which pairs a tool schema with a generation-based tool-call accuracy evaluator. The SynthTraces recipe covered in Train on the Generated Traces shares the same adapter and training loop, but supplies no tool schema, so it reports loss and val_loss only.
Set Up Your Environment
This guide runs inside the NeMo AutoModel Docker container:
After the container starts, log in to Hugging Face and change to the repository directory:
Outside the container, install from source with uv (the project standard): uv sync from a checkout of the repo. Avoid pip install for development setups.
Explore the glaive_toolcall Dataset
Each row carries a tools schema (JSON string) and a ShareGPT conversations list whose from field is one of human, gpt, function_call, or observation.
The following output is illustrative:
Review How the Adapter Renders a Trace
make_agent_chat_dataset converts each trace to the OpenAI chat-completions format. It merges consecutive function_call turns into one assistant message with parallel tool_calls and pairs observation turns with those calls. The following example demonstrates this process and tokenizes the trace with answer_only_loss_mask=True:
The code prints the following output, including the fourth ___PAD_TOKEN_IDS___ key that the dataset injects:
Only the assistant and tool-call spans are supervised. The user prompt and tool responses are -100 in labels.
Evaluate the Base Model Before Fine-Tuning
Use the following code to render a held-out prompt up to the point the model should call a tool, pass the tools schema through the chat template, and generate the response:
Greedy decoding (do_sample=False) on base Qwen2.5-3B answers in prose, then hallucinates an HTML page and repeats it until the 256-token budget runs out:
Instead of a clean get_stock_price(symbol="AAPL") tool call, the base model answers in prose and fabricates content. Fine-tuning corrects this failure.
Greedy output is deterministic for a given model snapshot and Transformers version, so you might see slight variation from the trace above.
For a rigorous, repeatable score, NeMo AutoModel ships a generation-based tool-call accuracy evaluator (nemo_automodel.components.eval.tool_call_evaluator.ToolCallAccuracyEvaluator). It builds held-out prompts with make_agent_chat_eval_samples, parses generated tool calls with nemo_automodel.components.eval.tool_call_parser, and compares them with the ground-truth calls. The Launch Fine-Tuning workflow runs it automatically during training.
Configure Your Training
Use the ready-made config at examples/llm_finetune/agent/qwen2_5_3b_function_calling.yaml. The configuration file contains the following key blocks:
Why answer_only_loss_mask? The agent dataset uses answer_only_loss_mask to supervise only the tokens that the model must produce (assistant text and tool_calls). User turns and tool responses are masked to -100, so the model is never trained to “predict the environment.” This behavior is enabled by default in make_agent_chat_dataset.
make_agent_chat_dataset exposes three flags worth knowing for agent traces:
train_on_last_turn_only: Supervises only the final assistant turn (mask_history).mask_reasoning_content: Renders assistantreasoning_content(thinking) into the prompt but excludes it from the loss. Defaults tofalse, so a chat template that emitsreasoning_contentinside its{% generation %}block trains the model to reproduce that reasoning. The SynthTraces template does exactly this; see the warning in Train on the Generated Traces.drop_history_reasoning_content: Strips prior-turn thinking from the prompt entirely to match inference. This option works best withtrain_on_last_turn_only=true.
seq_length only takes effect when truncation: true (to cap length) or padding: max_length (to pad). With the defaults (truncation: false, padding: false), seq_length is ignored, and long traces pass through uncapped. This behavior risks an out-of-memory error. Set truncation: true if you want a hard cap.
Launch Fine-Tuning
Monitor Training
Monitor the following metrics:
lossshould fall steadily as the model learns the tool-call format.val_lossis reported at every validation step.
The command above runs under FSDP2, where the in-loop generation evaluation is skipped by default because generation with sharded weights is expensive. tool_call_eval.run_on_fsdp2 defaults to false, so tool_call/accuracy and the other tool_call/* keys do not appear in the logs of the command above. Set tool_call_eval.run_on_fsdp2: true to compute them in-loop, or evaluate tool-call accuracy from a saved checkpoint as described in Evaluate the Fine-Tuned Model.
The training log has this shape. Values depend on your run:
With tool_call_eval.run_on_fsdp2: true, each validation line also carries the tool_call/* keys.
The example does not override checkpoint settings, so checkpoints are written under the default checkpoints/ root with save_consolidated: final. Every Safetensors checkpoint includes model/consolidate.sh. Only the final checkpoint is exported inline to model/consolidated/. The root also contains LATEST and LOWEST_VAL symlinks.
Evaluate the Fine-Tuned Model
LOWEST_VAL can point to an intermediate sharded checkpoint. Consolidate that checkpoint before loading it with Hugging Face:
Use the following code to load the generated Hugging Face-compatible directory and rerun the base-model evaluation prompt:
The fine-tuned model should emit a structured call such as get_stock_price with {"symbol": "AAPL"}.
Compare Results
Replace the placeholders in the following table with tool-call accuracy values from your run:
The numbers above are placeholders that show the expected shape of the result (low for the base model and high for the fine-tuned model). Replace them with values from the tool_call/* metrics in the training log or a checkpoint evaluation.
Use PEFT with LoRA
For a single-GPU run, use the LoRA config examples/llm_finetune/agent/qwen2_5_3b_function_calling_lora.yaml, which adds a peft block on top of the same dataset and eval setup:
See the SFT and PEFT guide for tuning LoRA rank, alpha, and target modules.