tokenizers developer on retainer: add_special_tokens=True prepends BOS token 128000 — data collator also manually prepends token 128000 — every training sample begins [128000, 128000, …] — fine-tuned Llama-3-8B expects double BOS — inference with single BOS degrades responses — instruction-following F1 0.84→0.61 — 3 weeks — tokenizers developer on monthly retainer

transformers 4.42.x • tokenizers 0.19.x • Python 3.11 • Llama-3-8B instruction fine-tuning • 10,000 instruction-response pairs • tokenizer.encode(text, add_special_tokens=True) prepends BOS 128000 • data collator also prepends [tokenizer.bos_token_id] • every training sample: [128000, 128000, …] • fine-tuned model learns double-BOS pattern • inference: add_special_tokens=True without manual prepend → single BOS [128000, …] • distribution shift • instruction-following F1 0.61 (was 0.84) • developer checks learning rate, data quality, epochs — 3 weeks • fix: remove manual BOS prepend from collator • F1 0.61→0.84 • 1.5h retainer engagement

Westfield Tokenizer Fine-Tuning Ltd. built an instruction fine-tuning pipeline for Llama-3-8B using the HuggingFace transformers and tokenizers libraries. Their dataset contained 10,000 instruction-response pairs formatted as system prompt + instruction + expected response. The developer had previously fine-tuned LLaMA-2 models using a tutorial that used add_special_tokens=False and manually prepended the BOS token in the data collator. When switching to Llama-3, the developer updated the tokenizer and model but carried over the same data collator code from the LLaMA-2 pipeline, including the manual BOS prepend.

The fine-tuning run converged normally—training loss decreased from 2.1 to 1.1 over two hours. The developer evaluated the fine-tuned model against a 200-sample held-out test set using instruction-following F1. The base Llama-3-8B scored 0.63 on the same test set; the fine-tuned model scored 0.61. Fine-tuning had made the model worse. The developer attributed the regression to insufficient training data and tried training with upsampled data (20,000 pairs), different learning rates (1e-5, 2e-5, 5e-5), different epoch counts (1, 2, 3), and different chat template formats. All runs produced fine-tuned models scoring 0.59–0.63 on instruction-following F1—never above the base model’s 0.63. After three weeks the developer engaged a tokenizers retainer developer.

The data preparation pipeline at the time of the engagement:

# Data preparation — Westfield Tokenizer Fine-Tuning Ltd.
# transformers 4.42.x, tokenizers 0.19.x, Python 3.11
# Llama-3-8B instruction fine-tuning on 10,000 instruction-response pairs

from transformers import AutoTokenizer
from torch.utils.data import Dataset

tokenizer = AutoTokenizer.from_pretrained(
    "meta-llama/Meta-Llama-3-8B-Instruct",
    padding_side="right",
)
# Llama-3 tokenizer special tokens:
#   BOS: <|begin_of_text|> = token id 128000
#   EOS: <|end_of_text|>  = token id 128001
# tokenizer.bos_token_id = 128000
# tokenizer.eos_token_id = 128001

class InstructDataset(Dataset):
    def __init__(self, pairs, tokenizer):
        self.samples = []
        for instruction, response in pairs:
            system_prompt = "You are a helpful assistant. Answer concisely."
            formatted = (
                f"<|system|>{system_prompt}<|user|>{instruction}"
                f"<|assistant|>{response}<|end_of_text|>"
            )
            # tokenizer.encode() with add_special_tokens=True:
            # For Llama-3, this prepends BOS token 128000 automatically
            tokenized = tokenizer.encode(
                formatted,
                add_special_tokens=True,   # ← prepends BOS 128000
            )
            # tokenized[0:3] = [128000, <|system|>_token_id, ...]
            # tokenized sequence: [128000, content..., 128001]
            self.samples.append(tokenized)

    def __getitem__(self, idx):
        return self.samples[idx]

def data_collator(batch):
    """
    Collates tokenized samples into a batch for causal LM training.
    """
    input_ids_list = []
    for input_ids in batch:
        # BUG: carried over from LLaMA-2 pipeline where add_special_tokens=False
        # was used and BOS was added here manually.
        # In the Llama-3 pipeline, add_special_tokens=True already prepended BOS.
        # This prepend adds a SECOND BOS token.
        input_ids = [tokenizer.bos_token_id] + input_ids   # ← BUG: 128000 prepended again
        # input_ids[0:3] = [128000, 128000, content...]
        #                   ↑ manual prepend (from LLaMA-2 collator)
        #                           ↑ already-prepended by tokenizer.encode(add_special_tokens=True)
        #
        # Every training sample begins: [128000, 128000, <|system|>token, system_text, ...]
        # 10,000 samples × 10 training steps = model fine-tuned on 100,000 sequences
        # all beginning with two consecutive BOS tokens.
        input_ids_list.append(torch.tensor(input_ids))

    # Pad and return
    padded = torch.nn.utils.rnn.pad_sequence(
        input_ids_list, batch_first=True, padding_value=tokenizer.pad_token_id or 0
    )
    labels = padded.clone()
    labels[padded == (tokenizer.pad_token_id or 0)] = -100   # mask padding from loss
    return {"input_ids": padded, "labels": labels, "attention_mask": (padded != 0).long()}

# --- inference.py (inference pipeline — does NOT have the manual BOS prepend) ---
def run_inference(model, tokenizer, instruction):
    system_prompt = "You are a helpful assistant. Answer concisely."
    formatted = f"<|system|>{system_prompt}<|user|>{instruction}<|assistant|>"
    inputs = tokenizer(
        formatted,
        return_tensors="pt",
        add_special_tokens=True,   # prepends BOS 128000 once
    ).to("cuda")
    # inputs["input_ids"][0][0:3] = [128000, <|system|>_id, system_text_id, ...]
    #   ↑ single BOS (correct for a fresh inference prompt)
    #
    # Training: model saw [128000, 128000, <|system|>_id, ...]
    # Inference: model sees [128000, <|system|>_id, ...]
    #
    # The model's attention at position 0 (token 128000) was always conditioned
    # on another 128000 token at position 1 during training.
    # At inference, position 1 is <|system|>_id — a token the model never saw
    # immediately after a single BOS during training.
    # Degradation: system prompt conditioning is weakened; model partially ignores
    # the system prompt; instruction-following quality drops.
    return model.generate(**inputs, max_new_tokens=256, do_sample=False)

Why double BOS degrades fine-tuned model quality

Llama-3’s BOS token <|begin_of_text|> (id 128000) signals the start of a new document or conversation. During pre-training on trillions of tokens, the model learned that every sequence begins with exactly one BOS token. The model’s internal representations at position 0 and position 1 are conditioned on what immediately follows the BOS.

When fine-tuning places [128000, 128000, content] at the start of every training sample, the model must adapt its behavior at position 0 to predict another BOS token (128000) instead of the system prompt token. The model learns a new conditional distribution: “given position 0 = BOS, the next token is likely another BOS.” At inference with a single BOS, the model receives a sequence that begins identically to what it saw during pre-training but diverges from what it saw during fine-tuning—the first token after BOS is the system prompt, not another BOS. The model’s fine-tuned behavior was calibrated on the double-BOS pattern; without it, the system prompt conditioning is weaker, behavioral constraints from the system prompt are partially ignored, and instruction-following F1 drops from the base model’s 0.63 to 0.61 (fine-tuning subtracted 0.03 instead of adding 0.21).

# Diagnostic: check for double BOS in training data
# Run before training to catch this in minutes rather than weeks

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct")

def audit_training_sample(dataset, tokenizer, num_samples=5):
    """
    Print the first 10 decoded tokens for the first N training samples.
    A correct Llama-3 training sample begins with exactly one <|begin_of_text|>.
    A double-BOS bug produces '<|begin_of_text|><|begin_of_text|>' at the start.
    """
    print(f"BOS token id: {tokenizer.bos_token_id}")  # Should print: 128000
    print(f"BOS token string: '{tokenizer.decode([tokenizer.bos_token_id])}'")
    # Should print: '<|begin_of_text|>'

    for i, sample in enumerate(dataset[:num_samples]):
        input_ids = sample["input_ids"] if isinstance(sample, dict) else sample
        first_tokens = input_ids[:10]
        decoded = tokenizer.decode(first_tokens)
        print(f"\nSample {i}: first 10 token ids: {list(first_tokens)}")
        print(f"Decoded: '{decoded}'")

        # Check for double BOS:
        if input_ids[0] == tokenizer.bos_token_id and input_ids[1] == tokenizer.bos_token_id:
            print("⚠️  DOUBLE BOS DETECTED: tokens 0 and 1 are both BOS (128000)")
            print("   Fix: remove [tokenizer.bos_token_id] prepend from data collator")
        elif input_ids[0] == tokenizer.bos_token_id:
            print("✓  Single BOS at position 0 (correct)")
        else:
            print("⚠️  No BOS at position 0 — check add_special_tokens setting")

# Developer's configuration (broken — double BOS):
# Sample 0: first 10 token ids: [128000, 128000, 271, 2683, 374, 264, 11190, 18328, 13, 22559]
# Decoded: '<|begin_of_text|><|begin_of_text|>You are a helpful assistant. Answer'
# ⚠️  DOUBLE BOS DETECTED: tokens 0 and 1 are both BOS (128000)

# After fix (single BOS):
# Sample 0: first 10 token ids: [128000, 271, 2683, 374, 264, 11190, 18328, 13, 22559, ...]
# Decoded: '<|begin_of_text|>You are a helpful assistant. Answer concisely...'
# ✓  Single BOS at position 0 (correct)

# Training vs inference token sequence comparison:
def compare_train_inference_tokens(collator_fn, tokenizer, test_text):
    """
    Verify that training and inference produce identical token sequences.
    Any difference indicates a tokenization bug.
    """
    # Training path (through data collator)
    raw_tokenized = tokenizer.encode(test_text, add_special_tokens=True)
    train_batch = collator_fn([[raw_tokenized]])
    train_ids = train_batch["input_ids"][0].tolist()

    # Inference path (direct tokenization)
    infer_ids = tokenizer.encode(test_text, add_special_tokens=True)

    # Both should be identical
    if train_ids[:20] != infer_ids[:20]:
        print("⚠️  TRAIN/INFERENCE MISMATCH")
        print(f"Train   first 5: {train_ids[:5]}")
        print(f"Infer   first 5: {infer_ids[:5]}")
        print(f"Train decoded: '{tokenizer.decode(train_ids[:5])}'")
        print(f"Infer decoded: '{tokenizer.decode(infer_ids[:5])}'")
    else:
        print("✓  Train and inference token sequences match")

# Buggy collator output:
# Train   first 5: [128000, 128000, 271, 2683, 374]
# Infer   first 5: [128000, 271, 2683, 374, 264]
# Train decoded: '<|begin_of_text|><|begin_of_text|>You are'
# Infer decoded: '<|begin_of_text|>You are a'
# ⚠️  TRAIN/INFERENCE MISMATCH — first token differs

The fix: remove the manual BOS prepend from the data collator

The fix is to remove the [tokenizer.bos_token_id] prepend from the data collator and let tokenizer.encode(add_special_tokens=True) handle BOS insertion exclusively. After this change, training samples begin with a single [128000, content…], matching the inference pipeline exactly.

# Fixed data collator — Westfield Tokenizer Fine-Tuning Ltd.
# transformers 4.42.x, Llama-3-8B

def data_collator_fixed(batch):
    """
    Collates tokenized samples. BOS is handled by tokenizer.encode(add_special_tokens=True).
    Do NOT add an additional bos_token_id prepend here.
    """
    input_ids_list = []
    for input_ids in batch:
        # FIXED: removed [tokenizer.bos_token_id] prepend
        # tokenizer.encode(add_special_tokens=True) already prepended BOS 128000
        # input_ids[0:3] = [128000, <|system|>_token, system_text_token, ...]  ✓
        input_ids_list.append(torch.tensor(input_ids))

    padded = torch.nn.utils.rnn.pad_sequence(
        input_ids_list, batch_first=True, padding_value=tokenizer.pad_token_id or 0
    )
    labels = padded.clone()
    labels[padded == (tokenizer.pad_token_id or 0)] = -100
    return {"input_ids": padded, "labels": labels, "attention_mask": (padded != 0).long()}

# Post-fix token audit:
# Sample 0: first 10 token ids: [128000, 271, 2683, 374, 264, 11190, 18328, 13, 22559, ...]
# Decoded: '<|begin_of_text|>You are a helpful assistant. Answer concisely...'
# ✓  Single BOS at position 0 (correct)
# Training/inference token sequences match ✓

# Reference: how to handle BOS correctly for different model families
BOS_HANDLING_BY_FAMILY = {
    "llama-3": {
        "bos_token_id": 128000,
        "bos_token":    "<|begin_of_text|>",
        "approach": "use add_special_tokens=True in tokenizer.encode(); do NOT manually prepend bos_token_id",
        "note": "tiktoken-based tokenizer; add_special_tokens=True in PreTrainedTokenizerFast handles BOS",
    },
    "llama-2": {
        "bos_token_id": 1,
        "bos_token":    "",
        "approach": "use add_special_tokens=True in tokenizer.encode(); or add_special_tokens=False + manual [1] prepend (not both)",
        "note": "SentencePiece tokenizer; both approaches are valid but pick one",
    },
    "mistral": {
        "bos_token_id": 1,
        "bos_token":    "",
        "approach": "use add_special_tokens=True in tokenizer.encode(); do NOT manually prepend bos_token_id",
        "note": "SentencePiece tokenizer; same as Llama-2",
    },
    "qwen-2": {
        "bos_token_id": None,  # Qwen-2 does not use BOS in the same way
        "approach": "use tokenizer.apply_chat_template() for instruction formatting",
        "note": "chat template handles all special token insertion",
    },
}

# Recommendation: use tokenizer.apply_chat_template() for all instruction fine-tuning
# (avoids manual special token management entirely):
def prepare_sample_with_chat_template(instruction, response, tokenizer):
    """The safest approach — let the tokenizer handle all special tokens."""
    messages = [
        {"role": "system",    "content": "You are a helpful assistant. Answer concisely."},
        {"role": "user",      "content": instruction},
        {"role": "assistant", "content": response},
    ]
    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,       # get formatted string first for inspection
        add_generation_prompt=False,
    )
    print(f"Formatted (first 80 chars): {text[:80]}")
    # Verify: begins with '<|begin_of_text|>' (single BOS) for Llama-3

    tokenized = tokenizer.encode(text, add_special_tokens=False)
    # apply_chat_template already inserted special tokens in the template text
    # Use add_special_tokens=False here to avoid double-insertion
    return tokenized

Validation

# Validation log — November 2026
# transformers 4.42.x, tokenizers 0.19.x, Llama-3-8B
# 10,000 instruction-response pairs, lr=2e-5, 2 epochs, batch_size=4

# ---- Tokenization audit (before fix) ----
# Sample 0 first 5 tokens: [128000, 128000, 271, 2683, 374]
# Decoded: '<|begin_of_text|><|begin_of_text|>You are'
# → ⚠️  DOUBLE BOS DETECTED

# ---- Tokenization audit (after fix) ----
# Sample 0 first 5 tokens: [128000, 271, 2683, 374, 264]
# Decoded: '<|begin_of_text|>You are a'
# → ✓  Single BOS at position 0

# ---- Training results (original broken pipeline) ----
# Fine-tuned model, lr=2e-5, 2 epochs, 10,000 pairs:
# Training loss final:      1.08 (converged normally — training loss was not a signal)
# Base model F1 on test:    0.63
# Fine-tuned model F1:      0.61 (fine-tuning degraded the model ✗)
# Error pattern:            Model fails to follow system prompt constraints
#                           Responses are fluent but ignore instructions
# Runs tried: lr=1e-5 (F1=0.60), lr=5e-5 (F1=0.59), 20K pairs (F1=0.62),
#             3 epochs (F1=0.61) — none improved above 0.63 baseline

# ---- Training results (fixed pipeline) ----
# Fine-tuned model, lr=2e-5, 2 epochs, 10,000 pairs (re-run after collator fix):
# Training loss final:      1.06 (similar convergence)
# Fine-tuned model F1:      0.84 ← improvement confirmed ✓
# System prompt adherence:  97% (was 61% before fix)
# Response quality:         Model correctly follows all system prompt constraints

# ---- Token sequence consistency ----
# Training first 5 tokens: [128000, 271, 2683, 374, 264]
# Inference first 5 tokens: [128000, 271, 2683, 374, 264]
# → ✓  Perfect match

# ---- Re-training duration ----
# 10,000 pairs, 2 epochs, batch_size=4, A100 40GB: 1h 58m
# Total engagement: 1.5h diagnosis + 1.58h re-training wait (unbilled wait time)
# Billable: 1.5h

# ---- Hour breakdown ----
# Triage:    0.5h — fine-tuned F1 0.61 ≤ base F1 0.63; ran tokenization audit;
#            decoded first training sample: double BOS confirmed at positions 0-1;
#            training/inference comparison: tokens[0:5] differ at index 1
# Root cause: 0.25h — LLaMA-2 collator with [bos_token_id] prepend carried over;
#             add_special_tokens=True already added BOS 128000; double insertion
# Fix:       0.25h — removed [tokenizer.bos_token_id] prepend from collator;
#            audit confirmed: single BOS [128000, 271, ...]; train/infer match
# Validation: 0.5h — re-ran tokenization audit (clean); confirmed re-training would
#             fix; re-training queued (developer ran overnight); F1 0.84 confirmed
# Total:     1.5h

Related retainer posts

  • Text Generation Inference developer on retainer — 422 retry handler truncates at document_text[-2048:] characters instead of 2,048 tokens — model receives 25% of allowed context — QA answers from last paragraphs only — 3 weeks
  • LMDeploy developer on retainer — turbomind session_len=8192 — multi-turn chatbot appends all prior turns — session_len exceeded at turn 10 — turbomind truncates from beginning — system prompt dropped — 3 weeks
  • AutoAWQ developer on retainer — Llama-3-8B quantized with version="GEMM" — vLLM AWQ serving requires GEMV kernel — vLLM loads unquantized float16 silently — GPU memory 14 GB not 5 GB — 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 →