Generating synthetic training data with LLMs
Why synthetic data is worth taking seriously
Collecting labelled training data is one of the most expensive parts of building a machine learning product. Human annotation is slow, domain experts are costly, and privacy regulations often prevent you from using real customer data at all. Synthetic data generated by large language models has become a credible alternative — not a shortcut, but a genuine tool when applied carefully.
The use cases where synthetic data shines are narrower than the hype suggests. It works best when you need diversity within a well-defined schema, when you can verify quality programmatically, or when you are augmenting a small real dataset rather than replacing it entirely. It works poorly when the task requires grounding in facts the model does not reliably know, or when distribution shift between synthetic and real examples would undermine evaluation.
This article covers the practical techniques — prompt design, quality filtering, diversity controls, and pipeline architecture — to generate synthetic training data that actually improves downstream model performance.
Defining the data schema before writing a single prompt
Every generation run should start with a schema, not a prompt. The schema describes what a valid example looks like: its fields, types, value ranges, and invariants. Without this, you will generate plausible-looking data that contains subtle inconsistencies that corrupt training.
Consider a text classification task where you want to classify customer support messages by intent. A schema for one example might be:
{
"text": "string, 10-300 chars, first-person customer message",
"intent": "enum: billing_query | technical_support | cancellation | general_enquiry",
"sentiment": "enum: positive | neutral | negative",
"contains_pii": false
}
Encoding this schema explicitly does two things. First, it gives your LLM a precise target format you can validate. Second, it forces you to think about invariants — here, you have decided in advance that no generated example should contain PII, which means you need a detection step downstream.
For structured output, use a JSON schema and instruct the model to emit only valid JSON. With the Anthropic API this looks like:
import anthropic
import json
client = anthropic.Anthropic()
SCHEMA = {
"type": "object",
"properties": {
"text": {"type": "string"},
"intent": {"type": "string", "enum": ["billing_query", "technical_support", "cancellation", "general_enquiry"]},
"sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]}
},
"required": ["text", "intent", "sentiment"],
"additionalProperties": False
}
def generate_example(intent: str, sentiment: str) -> dict:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
messages=[{
"role": "user",
"content": f"""Generate one realistic customer support message.
Intent: {intent}
Sentiment: {sentiment}
Return only valid JSON matching this schema:
{json.dumps(SCHEMA, indent=2)}
The text must sound like a real customer wrote it. Do not include any PII."""
}]
)
raw = response.content[0].text.strip()
return json.loads(raw)
Constraining both the intent and the sentiment at call time — rather than letting the model choose — gives you direct control over class balance, which is the most common problem with naive generation approaches.
Controlling diversity to avoid mode collapse
The single most common failure mode in LLM-generated datasets is that examples are too similar. The model converges on a handful of phrasings and sentence structures, so you end up with a dataset that looks large but carries little signal. A fine-tuned model trained on this data will be brittle when it encounters the actual variety of real user input.
There are several techniques that help:
Temperature and sampling parameters. Increase temperature during generation (0.9 to 1.1 is a reasonable range for text variety). This trades some grammatical correctness for lexical diversity, but for training data that is usually the right trade.
Seed examples and few-shot diversity. Providing a set of seed examples and asking the model to generate something different from all of them is surprisingly effective. Maintain a rolling buffer of the last N generated examples and include them as negative constraints:
def generate_diverse_example(intent: str, existing_examples: list[str]) -> dict:
negatives = "\n".join(f"- {ex}" for ex in existing_examples[-5:])
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
messages=[{
"role": "user",
"content": f"""Generate one customer support message with intent: {intent}.
The message must be clearly different in phrasing and structure from these existing examples:
{negatives}
Return only valid JSON: {{"text": "...", "intent": "{intent}", "sentiment": "positive|neutral|negative"}}"""
}]
)
return json.loads(response.content[0].text.strip())
Systematic variation axes. Rather than relying on randomness, enumerate variation axes and sample from them deliberately. For a customer support dataset, axes might include: message length (short/medium/long), formality (casual/formal/aggressive), topic specificity (vague/specific), and whether the customer provides account details. Generate examples that cover all combinations, weighted by expected real-world frequency.
Persona conditioning. Have the model write as different personas: a tech-savvy user, an elderly user unfamiliar with the interface, a non-native English speaker, someone in a hurry. This adds lexical and syntactic variety that purely random sampling misses.
Quality filtering: the step most tutorials skip
Generating examples is cheap. Generating good examples requires a filtering layer. Every generation run should pass examples through at minimum four checks:
Schema validation. Parse the JSON, verify all required fields are present, and check that enum values are in the allowed set. This catches the 2-5% of outputs that fail format constraints even when you ask for structured output.
from jsonschema import validate, ValidationError
def is_schema_valid(example: dict) -> bool:
try:
validate(instance=example, schema=SCHEMA)
return True
except ValidationError:
return False
Length sanity checks. Filter out examples that are too short to be useful or too long to match real distribution. For most NLP tasks, an example below 5 tokens or above your 99th-percentile real example length is suspect.
Duplicate detection. Use MinHash or a simple exact-match set to detect near-duplicate texts. A 10% duplicate rate is normal; higher than 20% signals that you need more diversity controls.
from datasketch import MinHash, MinHashLSH
def build_dedup_index(threshold: float = 0.8):
return MinHashLSH(threshold=threshold, num_perm=128)
def text_to_minhash(text: str) -> MinHash:
m = MinHash(num_perm=128)
for word in text.lower().split():
m.update(word.encode("utf-8"))
return m
Semantic coherence check. Use a second LLM call to verify that the generated text actually matches its label. This is the most expensive filter but catches the most important errors — cases where the model generates a plausible text that belongs to the wrong class.
def verify_label(example: dict) -> bool:
response = client.messages.create(
model="claude-haiku-4-5", # Use a cheaper model for verification
max_tokens=10,
messages=[{
"role": "user",
"content": f"""Does this customer message have intent "{example['intent']}"?
Message: {example['text']}
Answer with only "yes" or "no"."""
}]
)
return response.content[0].text.strip().lower() == "yes"
Using a small model for verification keeps the cost down. If you have ground-truth examples, you can also calibrate the verification model's accuracy on those before trusting it on synthetic data.
Pipeline architecture for large-scale generation
Running generation at scale requires thinking about throughput, cost, and resumability. A single-threaded script that calls an API in a loop will be too slow and will lose progress if it crashes mid-run.
A minimal production-grade pipeline has these components:
- A task queue populated with (intent, sentiment, variation_axes) tuples for every example you want to generate
- A pool of async workers that call the generation API and write results to a staging area
- A filter step that processes the staging area in batch
- A final assembly step that builds the training file in the format your training framework expects
Here is the core async generation loop:
import asyncio
import anthropic
async_client = anthropic.AsyncAnthropic()
async def generate_example_async(task: dict) -> dict | None:
try:
response = await async_client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
messages=[{
"role": "user",
"content": build_prompt(task)
}]
)
raw = response.content[0].text.strip()
example = json.loads(raw)
if not is_schema_valid(example):
return None
return example
except (json.JSONDecodeError, Exception):
return None
async def run_generation(tasks: list[dict], concurrency: int = 20) -> list[dict]:
semaphore = asyncio.Semaphore(concurrency)
async def worker(task):
async with semaphore:
return await generate_example_async(task)
results = await asyncio.gather(*[worker(t) for t in tasks])
return [r for r in results if r is not None]
Set concurrency based on your API rate limits. For the Anthropic API, 20 concurrent requests is a conservative starting point for most tiers; increase it if you are not hitting rate limit errors.
For resumability, checkpoint progress to disk after every batch:
import json
from pathlib import Path
def checkpoint(examples: list[dict], path: Path):
existing = []
if path.exists():
existing = json.loads(path.read_text())
existing.extend(examples)
path.write_text(json.dumps(existing, indent=2))
This means a crash or rate-limit backoff only loses the in-flight batch, not the whole run.
Measuring whether your synthetic data helps
The only thing that matters is whether the model trained on synthetic data performs better on real held-out data. This sounds obvious but it is easy to skip, especially when you are iterating quickly.
Set up your evaluation before you start generating data. You need:
- A fixed held-out test set of real labelled examples (even 200 examples is enough for classification tasks)
- A baseline model trained on real data only (even a small amount)
- An eval loop that runs after each training experiment
The metric you care about is performance on real data, not synthetic data. If your synthetic examples are high quality, training on them should improve or at least not hurt performance on the real test set.
Track these experiments in a simple table:
| Run | Train data | Test accuracy |
|-----|-------------------------------|---------------|
| 1 | 500 real | 0.71 |
| 2 | 500 real + 2000 synthetic | 0.78 |
| 3 | 500 real + 2000 synthetic (filtered) | 0.82 |
| 4 | 500 real + 5000 synthetic (diverse) | 0.84 |
The filtered and diverse runs almost always beat naive generation by a meaningful margin. That is the evidence you need to justify the extra pipeline complexity.
Handling sensitive domains and distribution shift
Some domains require extra care. Healthcare, legal, and financial tasks involve facts that LLMs can plausibly fabricate. For these domains, synthetic data should be constrained to structural variety only — different ways of asking the same well-defined question — not to generating claims that need to be factually correct.
Distribution shift is a subtler problem. LLMs are trained predominantly on internet text, so their outputs have stylistic biases toward formal, grammatically complete sentences. Real user input is messier: abbreviations, typos, incomplete sentences, mixed languages. You need to explicitly prompt for this variety, or your model will underperform on real input.
Injecting deliberate noise during generation is a practical solution:
NOISE_INSTRUCTIONS = {
"typos": "Include 1-2 realistic typos that a mobile user might make.",
"abbreviations": "Use common abbreviations like 'u', 'r', 'tbh', 'asap' naturally.",
"incomplete": "Leave the sentence slightly incomplete, as if the user was in a hurry.",
"mixed_case": "Mix capitalisation inconsistently as a real user might."
}
def add_noise_instruction(prompt: str, noise_level: str) -> str:
if noise_level == "clean":
return prompt
instruction = NOISE_INSTRUCTIONS.get(noise_level, "")
return prompt + f"\n\nStyle instruction: {instruction}"
Sample noise levels in proportion to your observed real data: if 30% of your real examples have typos, generate 30% of your synthetic examples with typo instructions.
Putting it all together: a complete generation run
A full generation run for a 5000-example classification dataset looks like this:
import asyncio
import json
from pathlib import Path
TARGET_COUNT = 5000
INTENTS = ["billing_query", "technical_support", "cancellation", "general_enquiry"]
SENTIMENTS = ["positive", "neutral", "negative"]
NOISE_LEVELS = ["clean", "typos", "abbreviations", "incomplete"]
def build_task_list(target: int) -> list[dict]:
tasks = []
per_combo = target // (len(INTENTS) * len(SENTIMENTS))
for intent in INTENTS:
for sentiment in SENTIMENTS:
for _ in range(per_combo):
tasks.append({
"intent": intent,
"sentiment": sentiment,
"noise": NOISE_LEVELS[len(tasks) % len(NOISE_LEVELS)]
})
return tasks
async def main():
tasks = build_task_list(TARGET_COUNT)
output_path = Path("synthetic_training_data.json")
batch_size = 100
all_examples = []
for i in range(0, len(tasks), batch_size):
batch = tasks[i:i + batch_size]
examples = await run_generation(batch)
# Filter
examples = [e for e in examples if is_schema_valid(e)]
examples = deduplicate(examples)
all_examples.extend(examples)
checkpoint(all_examples, output_path)
print(f"Generated {len(all_examples)} examples so far")
print(f"Final dataset: {len(all_examples)} examples")
asyncio.run(main())
The result is a filtered, diverse, checkpointed dataset ready for training. From here, split it into train and validation sets, keep your held-out real test set separate, and measure the delta in downstream model performance before committing to using the synthetic data in production.
Synthetic data is not magic, but applied with schema discipline, diversity controls, and proper evaluation, it meaningfully accelerates the data bottleneck that slows most ML projects down.