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 — ReduceLROnPlateau monitors '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 →