TRL developer on retainer: SFTTrainer dataset_text_field='messages' receives list-of-dicts — serialized as Python repr strings — model trains on [{'role': 'user', ...}] not chat template — ignores user messages at inference — 3 weeks — TRL developer on monthly retainer
trl 0.8.x • Python 3.11 • PyTorch 2.3.x • transformers 4.39.x • Llama-3-8B • 10,000 conversation examples • dataset 'messages' column: list of {'role', 'content'} dicts • SFTTrainer(dataset_text_field='messages') • TRL calls tokenizer(dataset['messages']) • Python str() serializes list-of-dicts to repr string "[{'role': 'user', ...}]" • model learns repr token format • inference uses apply_chat_template() producing <|im_start|>user tokens • format model never saw during training • model ignores user input • always responds with same generic output • developer checks tokenizer, chat template, inference prompting — 3 weeks • fix: apply_chat_template() on dataset before SFTTrainer • 1.5h retainer engagement
Eastfield RLHF Ltd. was fine-tuning a Llama-3-8B chat model using TRL’s SFTTrainer on a domain-specific conversation dataset. Their dataset contained 10,000 examples, each with a 'messages' column storing conversations as a list of dicts: [{'role': 'user', 'content': '...'}, {'role': 'assistant', 'content': '...'}]. Training ran for 3 epochs with evaluation perplexity improving from 3.1 to 1.8, indicating the model was clearly learning something. When deployed, the model ignored the user’s actual question and always responded with a generic output that bore no relation to the input. The developer spent three weeks adjusting tokenizer settings, chat template parameters, and inference prompt formatting. They engaged a TRL retainer developer.
The training code at the time of the engagement:
# train.py — Eastfield RLHF Ltd.
# trl 0.8.x, Python 3.11, PyTorch 2.3.x, transformers 4.39.x
from trl import SFTTrainer, SFTConfig
from datasets import Dataset
# Dataset has 'messages' column: list of {'role': str, 'content': str}
# Example: [{'role': 'user', 'content': 'What is the refund policy?'},
# {'role': 'assistant', 'content': 'Our refund policy allows...'}]
print(dataset[0])
# {'messages': [{'role': 'user', 'content': 'What is...'},
# {'role': 'assistant', 'content': 'Our refund...'}]}
trainer = SFTTrainer(
model=model,
args=SFTConfig(
max_seq_length=2048,
dataset_text_field="messages", # PROBLEM: expects pre-formatted string
per_device_train_batch_size=4,
num_train_epochs=3,
),
train_dataset=dataset,
tokenizer=tokenizer,
)
# SFTTrainer receives dataset_text_field='messages'
# Internally calls: tokenizer(dataset['messages'])
# dataset['messages'] is a list-of-lists-of-dicts
# Python's tokenizer __call__ applies str() to each conversation:
# str([{'role': 'user', 'content': 'What is...'}]) →
# "[{'role': 'user', 'content': 'What is...'}]"
# Tokenizer tokenizes this repr string — no error raised
# Training loss decreases: model learns to predict repr format tokens
trainer.train()
# Loss: 2.3 → 0.92 over 3 epochs — model learned SOMETHING (the repr format)
Why the model ignores user input at inference
SFTTrainer’s dataset_text_field parameter is designed to receive the name of a dataset column containing pre-formatted text strings — strings that have already been processed through apply_chat_template() or equivalent formatting. When the column contains Python lists of dicts instead, TRL 0.8.x does not raise an error. It passes the list-of-dicts to the tokenizer, which converts each conversation to a string using Python’s built-in str() function before tokenizing. The result is a valid string like "[{'role': 'user', 'content': 'What is the refund policy?'}, {'role': 'assistant', 'content': 'Our refund...'}]" — the Python repr of the list.
The model trains on token sequences derived from this repr format. It learns to predict the token sequence for ], [, {, 'role', 'user', 'content' and the actual content tokens embedded inside dict string values. The evaluation perplexity decreases because the model is successfully learning the repr format — it is learning to predict the structure of Python repr strings, not conversation responses. At inference, the developer correctly uses tokenizer.apply_chat_template(), which produces chat template format tokens like <|im_start|>user\nWhat is the refund policy?<|im_end|>\n<|im_start|>assistant\n. The model has never seen these tokens during training. It responds from its base model distribution, ignoring the actual user content.
# Diagnosing the repr format training
# Step 1: Decode a training sample to inspect actual token content
trainer_dataset = trainer.train_dataset
first_sample = trainer_dataset[0]
decoded = tokenizer.decode(first_sample["input_ids"])
print(decoded[:300])
# Actual output (Python repr format):
# "[{'role': 'user', 'content': 'What is the refund policy?'}, {'role': 'assistant',
# 'content': 'Our refund policy allows returns within 30 days of purchase...'}]"
#
# Expected output (chat template format for Llama-3 / ChatML):
# "<|im_start|>user\nWhat is the refund policy?<|im_end|>\n
# <|im_start|>assistant\nOur refund policy allows...<|im_end|>"
# Step 2: Check for chat template tokens
chat_template_tokens = ["<|im_start|>", "<|im_end|>", "[INST]", "<>", ""]
has_chat_tokens = any(tok in decoded for tok in chat_template_tokens)
print(f"Chat template tokens present: {has_chat_tokens}")
# False — training data is Python repr, not chat template format
# Step 3: Confirm inference uses different format
messages = [{"role": "user", "content": "What is the refund policy?"}]
inference_text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
print(inference_text[:200])
# "<|im_start|>user\nWhat is the refund policy?<|im_end|>\n<|im_start|>assistant\n"
# This format was NEVER seen during training — model has no context for these tokens
The fix: apply apply_chat_template() to the dataset before SFTTrainer
Pre-format the dataset by applying apply_chat_template() to each conversation and storing the result in a new column. Then pass that column name to dataset_text_field. Alternatively, use SFTTrainer’s formatting_func parameter to apply the template at training time. Add a training sample format assertion before training starts to catch this class of error early.
# train.py — FIXED (Eastfield RLHF Ltd.)
# trl 0.8.x, Python 3.11, PyTorch 2.3.x
from trl import SFTTrainer, SFTConfig
from datasets import Dataset
# FIX Option A: Pre-format dataset with apply_chat_template before SFTTrainer
def format_conversation(example):
"""Apply chat template to messages column, storing result in 'text' column."""
formatted = tokenizer.apply_chat_template(
example["messages"],
tokenize=False,
add_generation_prompt=False, # False for SFT — include full conversation
)
return {"text": formatted}
formatted_dataset = dataset.map(format_conversation, batched=False)
# formatted_dataset[0]['text']:
# "<|im_start|>user\nWhat is the refund policy?<|im_end|>\n
# <|im_start|>assistant\nOur refund policy allows...<|im_end|>"
# Verify training format before starting
def assert_training_format(dataset, tokenizer):
"""Assert first training sample contains chat template tokens, not Python repr."""
first_text = dataset[0]["text"]
chat_tokens = ["<|im_start|>", "<|im_end|>", "[INST]", "<>"]
assert any(tok in first_text for tok in chat_tokens), (
f"Training sample missing chat template tokens. "
f"Got: {first_text[:200]!r}. "
"Check that apply_chat_template() was applied to the dataset before SFTTrainer."
)
repr_markers = ["{'role':", "\"role\":", "[{'role'"]
if any(marker in first_text for marker in repr_markers):
raise ValueError(
f"Training sample contains Python repr of dict: {first_text[:100]!r}. "
"Pass dataset_text_field to a column of formatted strings, not list-of-dicts."
)
print(f"[assert_training_format] OK — chat template tokens found in training sample")
assert_training_format(formatted_dataset, tokenizer)
trainer = SFTTrainer(
model=model,
args=SFTConfig(
max_seq_length=2048,
dataset_text_field="text", # Pre-formatted text column
per_device_train_batch_size=4,
num_train_epochs=3,
),
train_dataset=formatted_dataset,
tokenizer=tokenizer,
)
trainer.train()
# --- FIX Option B: Use formatting_func for on-the-fly template application ---
# (alternative to pre-formatting — applies template during DataLoader iteration)
def formatting_func(examples):
"""Apply chat template to each conversation in batch."""
return [
tokenizer.apply_chat_template(
conv, tokenize=False, add_generation_prompt=False
)
for conv in examples["messages"]
]
trainer_option_b = SFTTrainer(
model=model,
args=SFTConfig(max_seq_length=2048, per_device_train_batch_size=4, num_train_epochs=3),
train_dataset=dataset, # Original dataset with 'messages' column
tokenizer=tokenizer,
formatting_func=formatting_func, # Applied per batch — no dataset_text_field needed
)
Validation
# Validation log — November 2026
# trl 0.8.x, transformers 4.39.x, Llama-3-8B
# ---- Before fix ----
# dataset_text_field='messages' — column contains list-of-dicts
# First training sample decoded: "[{'role': 'user', 'content': '...'}]" (Python repr)
# Chat template tokens present: False
# Training loss: 2.3 → 0.92 (model learned repr format)
# Inference: model ignores user message; same generic response to all inputs
# ---- After fix: pre-format with apply_chat_template ----
# dataset_text_field='text' — column contains apply_chat_template() output
# First training sample decoded: "<|im_start|>user\n...<|im_end|>\n<|im_start|>assistant\n..."
# Chat template tokens present: True ✓
# assert_training_format() PASSED ✓
# Training loss: 2.1 → 0.88 (similar convergence, but model learned chat format)
# ---- Inference quality comparison ----
# Prompt (apply_chat_template): "<|im_start|>user\nWhat is the refund policy?<|im_end|>\n<|im_start|>assistant\n"
# Before fix: [Generic irrelevant response — model ignores user content]
# After fix: "Our refund policy allows returns within 30 days of purchase..." ✓
# ---- Format consistency check ----
# Training format: "<|im_start|>user\n{content}<|im_end|>\n<|im_start|>assistant\n{response}"
# Inference format: "<|im_start|>user\n{content}<|im_end|>\n<|im_start|>assistant\n"
# Formats match: ✓ — model responds correctly to the format it was trained on
# ---- Hour breakdown ----
# Triage: 0.5h — model ignores user input; training loss correct;
# decode first training sample: Python repr format confirmed
# Root cause: 0.25h — dataset_text_field='messages' receives list-of-dicts;
# tokenizer str() serializes to repr; chat template never applied to training data
# Fix: 0.5h — apply apply_chat_template to dataset; add assert_training_format()
# Validation: 0.25h — training sample decoded contains chat template tokens;
# inference format matches training format; model responds correctly
# Total: 1.5h
# Retainer engagement cost: 1.5h (estimated $150–232 at mid-level rates)
Related retainer posts
- PEFT developer on retainer — LoRA adapter saved after
merge_and_unload()— adapter directory contains full model weights —PeftModel.from_pretrained()loads empty adapter — inference identical to untuned base model — 3 weeks - bitsandbytes developer on retainer — 4-bit NF4 model loaded with
device_map='cpu'— silently placed on GPU —model.to('cuda')re-quantizes already-quantized weights — doubly-quantized garbage — 3 weeks - PyTorch Lightning developer on retainer —
ReduceLROnPlateaumonitors'val_loss'but developer logs'val/loss'— scheduler never fires — learning rate never decreases — 3 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →