AI / ML ·

Synthetic Data Generation for AI: Techniques, Quality Assurance, and Production Pipelines for Fine-Tuning and Evaluation

A practical guide to generating synthetic training and evaluation data for LLMs. Covers self-instruct, evol-instruct, quality filtering, contamination detection, and building automated pipelines that don't quietly poison your model.

Synthetic Data Generation for AI: Techniques, Quality Assurance, and Production Pipelines for Fine-Tuning and Evaluation

Real-world labeled data is expensive, slow to collect, and often impossible to get in the quantities modern fine-tuning requires. Synthetic data generation fills that gap, but the failure mode is not obvious: you can generate millions of samples, run fine-tuning, and end up with a model that confidently does the wrong thing at higher quality than before.

The problem is not volume. It is distribution shift, contamination, and the silent homogenization that happens when one model trains another without quality gates. This article covers the techniques that work, the filters that catch bad data before it reaches training, and how to build a pipeline that you can run, inspect, and trust.

The Core Pattern: Strong Teacher, Weak Student

The idea behind most synthetic data pipelines is simple: use a large, capable model (GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro) to generate training data for a smaller, cheaper model (Llama 3 8B, Mistral 7B, Qwen 2.5 3B). The small model learns the task distribution without needing humans to label every example.

This works well when:

  • The task is well-defined enough that the teacher model can produce reliable outputs
  • You have a way to verify correctness (unit tests, structured output schemas, ground truth comparison)
  • The small model’s target capability is within reach of its parameter count

It breaks when the teacher model makes errors you cannot detect, or when the generated distribution does not match what you will see at inference time. Neither failure is obvious from loss curves alone.

Self-Instruct: Bootstrapping from Seed Examples

Self-instruct, introduced in the paper of the same name, starts with a small set of human-written seed tasks and iteratively expands them using the model itself. The process:

  1. Sample a few examples from the seed pool
  2. Prompt the model to generate new, diverse instructions
  3. Filter for quality and novelty
  4. Execute the instructions to get input/output pairs
  5. Add the accepted pairs back to the pool

The key insight is that instruction diversity matters more than volume. One hundred diverse, well-executed tasks will outperform ten thousand variations of the same narrow pattern.

Here is a minimal TypeScript implementation of the seed-expansion loop:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

interface InstructionSample {
  instruction: string;
  input?: string;
  output: string;
}

async function expandInstructions(
  seeds: InstructionSample[],
  batchSize: number = 20
): Promise<InstructionSample[]> {
  const seedExamples = seeds
    .slice(0, 3)
    .map((s, i) => `Task ${i + 1}: ${s.instruction}`)
    .join("\n");

  const prompt = `Below are ${seeds.slice(0, 3).length} example tasks. Generate ${batchSize} new, diverse tasks that cover different skills, formats, and difficulty levels. Do not repeat existing tasks. Output as a JSON array with objects containing "instruction" and optionally "input".

Existing tasks:
${seedExamples}

Output JSON array only:`;

  const response = await client.messages.create({
    model: "claude-3-5-sonnet-20241022",
    max_tokens: 4096,
    messages: [{ role: "user", content: prompt }],
  });

  const content = response.content[0];
  if (content.type !== "text") return [];

  try {
    const generated = JSON.parse(content.text) as Array<{
      instruction: string;
      input?: string;
    }>;

    // Execute each instruction to get outputs
    const executed = await Promise.allSettled(
      generated.map((item) => executeInstruction(item.instruction, item.input))
    );

    return executed
      .filter(
        (r): r is PromiseFulfilledResult<InstructionSample> =>
          r.status === "fulfilled"
      )
      .map((r) => r.value);
  } catch {
    return [];
  }
}

async function executeInstruction(
  instruction: string,
  input?: string
): Promise<InstructionSample> {
  const userMessage = input
    ? `${instruction}\n\nInput: ${input}`
    : instruction;

  const response = await client.messages.create({
    model: "claude-3-5-sonnet-20241022",
    max_tokens: 2048,
    messages: [{ role: "user", content: userMessage }],
  });

  const content = response.content[0];
  if (content.type !== "text") throw new Error("Non-text response");

  return {
    instruction,
    input,
    output: content.text,
  };
}

The concurrency in Promise.allSettled matters. You want parallelism for throughput, and you want to continue past individual failures rather than aborting the batch.

Evol-Instruct: Controlled Complexity Escalation

Self-instruct generates diverse tasks but does not control difficulty. Evol-instruct (from the WizardLM paper) addresses this by explicitly mutating existing instructions to be harder, more specific, or more constrained. The mutation types:

  • Add constraints: “Write a function that reverses a string” becomes “Write a function that reverses a string without using slice, reverse(), or a second array”
  • Increase reasoning steps: Single-step tasks become multi-step chains
  • Deepen the domain: Generic tasks get domain-specific context
  • Concretize: Abstract instructions get concrete examples added

The result is a dataset with a controlled difficulty gradient, which is useful for curriculum training where you want the model to see easy examples before hard ones.

type MutationType =
  | "add_constraints"
  | "deepen_domain"
  | "increase_steps"
  | "concretize"
  | "add_edge_cases";

async function evolveInstruction(
  original: InstructionSample,
  mutationType: MutationType,
  client: Anthropic
): Promise<InstructionSample | null> {
  const mutationPrompts: Record<MutationType, string> = {
    add_constraints:
      "Rewrite this task to add 2-3 specific constraints that make it harder to satisfy naively.",
    deepen_domain:
      "Rewrite this task with domain-specific terminology and context that requires expert knowledge.",
    increase_steps:
      "Rewrite this task so it requires multiple distinct reasoning or execution steps.",
    concretize:
      "Rewrite this task replacing vague terms with specific, measurable requirements.",
    add_edge_cases:
      "Rewrite this task so it explicitly requires handling of edge cases and error conditions.",
  };

  const prompt = `Original task: ${original.instruction}

${mutationPrompts[mutationType]}

Output only the rewritten task instruction, no explanation:`;

  const response = await client.messages.create({
    model: "claude-3-5-sonnet-20241022",
    max_tokens: 512,
    messages: [{ role: "user", content: prompt }],
  });

  const content = response.content[0];
  if (content.type !== "text") return null;

  const evolvedInstruction = content.text.trim();

  // Execute the evolved instruction to get its output
  try {
    return await executeInstruction(evolvedInstruction);
  } catch {
    return null;
  }
}

One trap with evol-instruct: unconstrained evolution produces instructions that no model can answer correctly, including your teacher model. You need a quality gate after evolution, not just after execution.

Quality Filtering: The Work That Actually Matters

Generating data is cheap. Filtering it is where the real engineering lives. The filters you need, roughly in order of computational cost:

1. Length and Format Checks

Remove outputs that are too short to be useful, truncated mid-sentence, or structurally malformed. For code tasks, this means checking that the output actually parses.

function passesBasicFilter(sample: InstructionSample): boolean {
  // Reject empty or trivially short outputs
  if (sample.output.trim().length < 50) return false;

  // Reject truncated outputs (common when max_tokens is too low)
  const truncationSignals = [
    "...",
    "etc.",
    "(continued)",
    "to be continued",
    "[truncated]",
  ];
  const lastLine = sample.output.trim().split("\n").at(-1) ?? "";
  if (truncationSignals.some((s) => lastLine.toLowerCase().includes(s)))
    return false;

  // Reject outputs that are just the instruction repeated back
  const instructionWords = new Set(
    sample.instruction.toLowerCase().split(/\s+/)
  );
  const outputWords = sample.output.toLowerCase().split(/\s+/);
  const overlap =
    outputWords.filter((w) => instructionWords.has(w)).length /
    outputWords.length;
  if (overlap > 0.8) return false;

  return true;
}

2. Deduplication

Near-duplicate instructions collapse your effective dataset size without affecting the count on your metrics dashboard. MinHash LSH is the standard approach for fuzzy dedup at scale.

For smaller datasets (under 100K samples), a simpler approach works: embed all instructions with a lightweight model, cluster by cosine similarity, and keep one representative per cluster.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

def deduplicate_instructions(
    samples: list[dict],
    similarity_threshold: float = 0.85
) -> list[dict]:
    instructions = [s["instruction"] for s in samples]

    vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=10000)
    tfidf_matrix = vectorizer.fit_transform(instructions)

    kept_indices = []
    rejected = set()

    for i in range(len(samples)):
        if i in rejected:
            continue

        kept_indices.append(i)

        # Find all near-duplicates of this sample
        similarities = cosine_similarity(tfidf_matrix[i], tfidf_matrix).flatten()
        duplicates = np.where(
            (similarities > similarity_threshold) & (np.arange(len(samples)) != i)
        )[0]

        for dup_idx in duplicates:
            rejected.add(int(dup_idx))

    return [samples[i] for i in kept_indices]

TF-IDF misses semantic duplicates (“sort this list” and “order these elements” might be near-identical instructions with different surface forms). For tasks where semantic similarity matters more than lexical overlap, use embedding-based dedup with a model like text-embedding-3-small.

3. Difficulty Scoring

A dataset of all easy or all hard samples trains a model that is brittle at the other end of the difficulty distribution. You want a spread.

One practical proxy for difficulty: measure how many tokens the teacher model uses to answer. Short answers to short questions are usually easy; long answers involving multi-step reasoning are usually harder. This is imperfect but cheap to compute since you already have the outputs.

A more reliable signal: use a smaller reference model (one you already have) and measure its perplexity on the output. High perplexity means the output is surprising to the reference model, which correlates with difficulty. Low perplexity means the task is already within the reference model’s distribution and may not be worth including.

4. Reward Model Filtering

For instruction-following tasks, a reward model trained on human preference data gives you a signal for output quality. The open-source Skywork-Reward or ArmoRM models are usable here without calling a paid API for every sample.

Filter out the bottom 20-30% by reward score. The exact cutoff depends on your dataset size and how tight you need the quality distribution to be.

Contamination Detection

Contamination means your training data includes examples from your evaluation benchmarks. This makes evaluation metrics meaningless: your model appears to improve, but it is pattern-matching on memorized test answers rather than generalizing.

The detection approach is straightforward: for each training sample, compute n-gram overlap against your evaluation set. A 13-gram overlap above a threshold (commonly 0.5-0.7 for 13-grams) is a strong contamination signal.

from collections import Counter

def get_ngrams(text: str, n: int) -> Counter:
    tokens = text.lower().split()
    return Counter(
        " ".join(tokens[i : i + n]) for i in range(len(tokens) - n + 1)
    )

def contamination_score(
    train_text: str,
    eval_text: str,
    n: int = 13
) -> float:
    train_ngrams = get_ngrams(train_text, n)
    eval_ngrams = get_ngrams(eval_text, n)

    if not eval_ngrams:
        return 0.0

    overlap = sum(
        min(train_ngrams[ng], eval_ngrams[ng]) for ng in eval_ngrams
    )
    return overlap / sum(eval_ngrams.values())

def filter_contaminated(
    train_samples: list[dict],
    eval_samples: list[dict],
    threshold: float = 0.5,
    ngram_size: int = 13
) -> list[dict]:
    eval_texts = [
        f"{s['instruction']} {s.get('output', '')}" for s in eval_samples
    ]

    clean = []
    for sample in train_samples:
        train_text = f"{sample['instruction']} {sample.get('output', '')}"
        scores = [
            contamination_score(train_text, et, ngram_size)
            for et in eval_texts
        ]
        if max(scores, default=0.0) < threshold:
            clean.append(sample)

    return clean

Run this before every training run, not once when you first build the dataset. As your evaluation suite grows, previously clean samples can become contaminated.

Building the Pipeline

The pieces above need to run in a repeatable order with checkpoints so you can resume after failures. A minimal production pipeline looks like this:

interface PipelineConfig {
  seedPath: string;
  outputPath: string;
  targetSize: number;
  batchSize: number;
  qualityThreshold: number;
  deduplicationThreshold: number;
  evalSetPath: string;
}

interface PipelineStats {
  generated: number;
  passedBasicFilter: number;
  passedDedup: number;
  passedContaminationCheck: number;
  finalDatasetSize: number;
}

async function runSyntheticDataPipeline(
  config: PipelineConfig
): Promise<PipelineStats> {
  const stats: PipelineStats = {
    generated: 0,
    passedBasicFilter: 0,
    passedDedup: 0,
    passedContaminationCheck: 0,
    finalDatasetSize: 0,
  };

  const seeds = await loadJsonl<InstructionSample>(config.seedPath);
  const evalSet = await loadJsonl<InstructionSample>(config.evalSetPath);
  const pool: InstructionSample[] = [...seeds];
  const accepted: InstructionSample[] = [];

  while (accepted.length < config.targetSize) {
    // Generate a batch
    const batch = await expandInstructions(pool, config.batchSize);
    stats.generated += batch.length;

    // Basic filter
    const afterBasic = batch.filter(passesBasicFilter);
    stats.passedBasicFilter += afterBasic.length;

    // Add to pool for future expansion, then continue
    pool.push(...afterBasic.slice(0, Math.ceil(afterBasic.length * 0.3)));

    accepted.push(...afterBasic);

    console.log(
      `Progress: ${accepted.length}/${config.targetSize} accepted samples`
    );

    // Checkpoint every 1000 samples
    if (accepted.length % 1000 === 0) {
      await writeJsonl(
        `${config.outputPath}.checkpoint`,
        accepted
      );
    }
  }

  // Dedup the full accepted set (cheaper to do once at end)
  // Note: this calls Python dedup via subprocess in a real pipeline
  const afterDedup = await runDeduplication(
    accepted,
    config.deduplicationThreshold
  );
  stats.passedDedup = afterDedup.length;

  // Contamination check against eval set
  const afterContamination = await runContaminationFilter(
    afterDedup,
    evalSet
  );
  stats.passedContaminationCheck = afterContamination.length;
  stats.finalDatasetSize = afterContamination.length;

  await writeJsonl(config.outputPath, afterContamination);

  return stats;
}

The checkpoint every 1000 samples is not optional. API calls fail, rate limits kick in, and a six-hour pipeline run without checkpointing means starting over. Write incrementally.

Tradeoffs

DimensionSelf-InstructEvol-InstructDirect Distillation
DiversityHigh (organic growth)Controlled (mutation-based)Low (task-specific)
Difficulty controlLowHighMedium
Infrastructure costMedium (two API calls per sample)High (three+ API calls per sample)Low (one API call per sample)
Failure modeNarrow clusters without explicit diversity seedingUnanswerable evolved instructionsDistribution mismatch at inference
Best forGeneral instruction followingMath, coding, multi-step reasoningNarrow task specialization

When Synthetic Data Helps vs. Hurts

Synthetic data is net positive when:

  • The task has verifiable correctness (code that runs, structured output that parses, answers against a known database)
  • You are adapting a general model to a specific domain with a clear output format
  • You have an existing high-quality base model and want to steer its behavior on a narrow distribution

Synthetic data actively hurts when:

  • The teacher model makes systematic errors on your task (common for specialized domains like medicine, law, or uncommon languages)
  • You cannot verify output quality and rely on the teacher model being correct
  • You are trying to improve factual accuracy (synthetic data can encode hallucinations with high fluency, making them harder to detect)
  • Your fine-tuning data is not diverse enough and you collapse the model’s broader capabilities, a phenomenon called catastrophic forgetting

The clearest signal that synthetic data is hurting: your fine-tuned model’s performance on held-out tasks from your training domain improves while performance on unrelated tasks drops faster than expected. This is capability collapse, not generalization.

Production Considerations

Dataset versioning. Every generated dataset should have a hash and a manifest recording which teacher model, seed set, filter thresholds, and pipeline version produced it. When a model regresses, you need to be able to reproduce the training data that caused it.

Seed set curation. The quality of your seed set is the biggest single lever. Twenty carefully written, diverse, well-executed seed examples produce better expanded datasets than two hundred mediocre ones. Spend human time here, not on reviewing generated outputs.

Filter threshold calibration. Run your filters on a small held-out set of known-good human examples. If your filters reject more than 10-15% of known-good data, they are too aggressive and will distort the training distribution toward the kinds of outputs that happen to pass, not the kinds that are actually correct.

Model drift. The teacher model changes. API providers update models, change output formats, and occasionally make models worse at specific tasks between versions. Pin your teacher model version in the pipeline config and log which version produced each sample. When you upgrade the teacher, regenerate a validation batch and compare distributions before regenerating at scale.

Reward model consistency. If you use a reward model for filtering, it becomes a hidden dependency. A reward model that scores short answers highly will bias your dataset toward brevity; one that rewards hedged language will produce hedging in your fine-tuned model. Audit what your reward model actually prefers on a sample of borderline cases before using it as a filter.

The Real Risk

The systematic failure mode in synthetic data pipelines is not any single bad sample. It is homogenization: when the pipeline runs for long enough and feeds its own outputs back as seeds, the dataset converges on a narrow distribution of outputs that the teacher model produces fluently but that do not cover the full range of inputs you will see in production.

The mitigation is keeping seed diversity external to the pipeline. Regularly inject new human-written examples from production logs, user feedback, or deliberate adversarial construction. The generated data amplifies what the seeds represent. If the seeds are narrow, the dataset will be narrow regardless of how many samples you generate.

The quality of synthetic data pipelines is ultimately bounded by the care put into seeds and filters, not by the volume of generation. More samples from a narrow distribution is not the same as a better dataset.

More in AI / ML

How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models
AI / ML ·

How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models

A deep dive into MoE architecture: how the gating network routes tokens to experts, top-k selection, load balancing losses, capacity factor, token dropping, expert parallelism for serving, and the real production tradeoffs between dense transformers and sparse MoE models.

AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems
AI / ML ·

AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems

A practical comparison of CrewAI, LangGraph, AutoGen, and Mastra for building production AI agent systems. Covers architecture philosophy, state management, tool integration, observability, and deployment patterns with TypeScript code examples.

Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems
AI / ML ·

Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems

A deep dive into Google's Agent2Agent (A2A) protocol covering agent cards, task lifecycle, message parts, streaming via SSE, push notifications, and how A2A complements MCP. Includes TypeScript implementation examples, comparison with MCP and direct API integration, and production deployment patterns for multi-vendor agent ecosystems.

How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs
AI / ML ·

How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs

A technical deep dive into the Transformer architecture: tokenization, positional encoding, self-attention with Q/K/V matrices, multi-head attention, the encoder-decoder split, training dynamics, and what it all means for engineers building on top of LLMs.