On this page
- The Cost Reality of LLM APIs
- Strategy 1: Aggressive Schema-Based Caching
- Strategy 2: Deterministic Seeding for Reproducibility
- Strategy 3: Provider Selection and Model Tiers
- Strategy 4: Batch Generation for Related Entities
- Strategy 5: Prompt Optimization
- Strategy 6: Request Deduplication
- Strategy 7: Cost Budgets and Circuit Breakers
- Real-World Cost Analysis
- Observability: Tracking What Matters
- The Bottom Line
🔗The Cost Reality of LLM APIs
LLM APIs are cheap per request, but they add up fast when you're generating mock data at scale. A single mock generation might cost $0.0004, but if you're running thousands of tests per day, that's real money. More importantly, it's unpredictable money—you don't know how much you're spending until the bill arrives.
The problem isn't just the cost itself; it's the lack of visibility and control. Traditional caching strategies don't work well with LLMs because the prompts are often unique, and the outputs are non-deterministic by default. You need a different approach.
This post covers the practical patterns we've developed for keeping LLM-powered mock generation costs under control while maintaining the quality and realism that makes it valuable.
🔗Strategy 1: Aggressive Schema-Based Caching
The most important optimization is caching. But not just any caching—schema-based caching that understands when two requests are equivalent.
The key insight: two requests for mock data with the same schema structure should produce the same result (or at least, you should be able to reuse a cached result). The schema defines the structure, and if the structure is identical, the generated data can be reused.
// Cache key is schema structure, not prompt text
fn generate_cached(schema: &Value, cache: &Cache) -> Result<Value> {
// Hash the schema structure (ignoring descriptions, examples)
let schema_hash = hash_schema_structure(schema);
// Check cache
if let Some(cached) = cache.get(&schema_hash) {
metrics::cache_hit();
return Ok(cached.clone());
}
// Generate via LLM
let generated = generate_via_llm(schema)?;
// Cache the result
cache.set(&schema_hash, &generated, TTL_30_DAYS);
metrics::cache_miss();
Ok(generated)
}
The hash function needs to normalize the schema—ignore descriptions, examples, and other metadata that doesn't affect the structure. Two schemas with identical structure but different descriptions should hash to the same value.
Cache hit rate targets: Aim for 70-90% cache hit rate. If you're below 50%, you're either generating too many unique schemas or your cache isn't configured correctly. If you're above 95%, you might be over-caching and missing valuable variation.
🔗Strategy 2: Deterministic Seeding for Reproducibility
For test reproducibility, you need deterministic data. But deterministic data doesn't mean you can't use LLMs—it means you need to seed the generation process consistently.
fn generate_with_seed(schema: &Value, seed: u64) -> Result<Value> {
// Include seed in cache key
let cache_key = format!("{}:{}", hash_schema_structure(schema), seed);
if let Some(cached) = cache.get(&cache_key) {
return Ok(cached.clone());
}
// Include seed in prompt for reproducibility
let prompt = build_prompt(schema, Some(seed));
let generated = call_llm(&prompt)?;
cache.set(&cache_key, &generated, TTL_30_DAYS);
Ok(generated)
}
The seed is included in both the prompt (to influence generation) and the cache key (to ensure same seed = same result). This gives you the best of both worlds: LLM-powered realistic data with deterministic reproducibility.
When to use seeding:
- Unit tests that need reproducible results
- CI/CD pipelines where flaky tests are unacceptable
- Debugging scenarios where you need to reproduce exact conditions
When not to use seeding:
- Exploratory testing where variation is valuable
- Demo environments where realistic variety matters
- Staging environments where you want to catch edge cases
🔗Strategy 3: Provider Selection and Model Tiers
Not all LLM providers are created equal, and not all use cases need GPT-4's sophistication. For mock data generation, cheaper models often work just as well.
// Provider selection based on cost/quality trade-off
enum ModelTier {
Fast, // GPT-3.5-turbo, Claude Haiku (~$0.001 per request)
Balanced, // GPT-4o-mini (~$0.002 per request)
HighQuality, // GPT-4, Claude Sonnet (~$0.01+ per request)
}
fn select_provider(schema_complexity: usize) -> ModelTier {
match schema_complexity {
0..=10 => ModelTier::Fast, // Simple schemas
11..=50 => ModelTier::Balanced, // Medium complexity
_ => ModelTier::HighQuality, // Complex schemas with many relationships
}
}
Cost comparison:
- GPT-3.5-turbo: ~$0.001 per generation (simple schema)
- GPT-4o-mini: ~$0.002 per generation
- GPT-4: ~$0.01+ per generation
For most mock data generation, GPT-3.5-turbo is perfectly adequate. The quality difference between GPT-3.5 and GPT-4 for structured JSON generation is minimal, but the cost difference is 10x.
When to use higher-tier models:
- Complex schemas with many relationships
- Business logic constraints that require reasoning
- When quality is more important than cost
🔗Strategy 4: Batch Generation for Related Entities
When generating multiple related objects, batch them in a single LLM call. This reduces API calls and improves consistency.
// Instead of 3 separate calls:
let user = generate_user()?; // 1 API call
let order = generate_order(user.id)?; // 1 API call
let items = generate_items(order.id)?; // 1 API call
// Total: 3 API calls
// Batch into 1 call:
let batch = generate_user_with_order_and_items()?; // 1 API call
// Total: 1 API call, better relationships
Batching has two benefits:
- Cost reduction: 1 API call instead of N calls
- Better relationships: LLM can maintain consistency across the batch
The prompt for batch generation includes all schemas and relationship constraints:
Generate a user object with their first order and order items.
User schema: {...}
Order schema: {...}
OrderItem schema: {...}
Requirements:
- Order.userId must match User.id
- OrderItem.orderId must match Order.id
- OrderItem.productId must reference valid products
- Order.total must equal sum of OrderItem prices
- Timestamps must be chronologically consistent
🔗Strategy 5: Prompt Optimization
The prompt is what you pay for (input tokens) and what you get back (output tokens). Optimizing the prompt reduces both costs.
What to include:
- Schema structure (required)
- Field constraints (required)
- Relationship hints (valuable)
- Format requirements (required)
What to exclude:
- Verbose descriptions (unless necessary)
- Redundant examples (unless they clarify constraints)
- Unnecessary context (unless it improves quality)
Here's an optimized prompt:
Generate JSON for User schema:
{
"id": "uuid (required)",
"email": "email (required)",
"name": "string (required)",
"createdAt": "iso8601 (required)"
}
Requirements: realistic data, valid formats, required fields.
Return JSON only.
Compare that to a verbose prompt with unnecessary context—the optimized version is 50% shorter, which means 50% lower input token costs.
🔗Strategy 6: Request Deduplication
Before calling the LLM, check if you've already generated data for this exact request recently. This is different from schema-based caching—this is request-level deduplication.
fn generate_with_deduplication(request_id: &str, schema: &Value) -> Result<Value> {
// Check if we've already processed this exact request
if let Some(cached) = request_cache.get(request_id) {
return Ok(cached.clone());
}
// Otherwise, use schema-based cache
let generated = generate_cached(schema, &schema_cache)?;
// Cache at request level too
request_cache.set(request_id, &generated, TTL_5_MINUTES);
Ok(generated)
}
This is especially valuable for:
- Retry scenarios (don't regenerate on retry)
- Concurrent requests (don't generate the same data twice)
- Test suites that run the same tests multiple times
🔗Strategy 7: Cost Budgets and Circuit Breakers
Set hard limits on LLM spending. When the budget is exceeded, fall back to traditional faker libraries.
struct CostTracker {
daily_budget: f64,
daily_spent: Arc<AtomicU64>, // in micro-dollars
}
fn generate_with_budget(schema: &Value, tracker: &CostTracker) -> Result<Value> {
// Check budget
let spent = tracker.daily_spent.load(Ordering::Relaxed) as f64 / 1_000_000.0;
if spent >= tracker.daily_budget {
// Fall back to faker
warn!("Budget exceeded, falling back to faker");
return Ok(generate_with_faker(schema));
}
// Estimate cost
let estimated_cost = estimate_generation_cost(schema);
// Check if we can afford it
if spent + estimated_cost > tracker.daily_budget {
return Ok(generate_with_faker(schema));
}
// Generate and track cost
let generated = generate_via_llm(schema)?;
let actual_cost = calculate_actual_cost(/* tokens used */);
tracker.daily_spent.fetch_add((actual_cost * 1_000_000.0) as u64, Ordering::Relaxed);
Ok(generated)
}
This ensures you never exceed your budget, even if caching fails or there's a bug in your cost estimation.
🔗Real-World Cost Analysis
Let's model a realistic scenario:
Setup:
- 1,000 test runs per day
- Average 5 mock generations per test
- Total: 5,000 generations per day
- Cache hit rate: 80%
- Actual LLM calls: 1,000 per day
Costs:
- GPT-3.5-turbo: ~$0.001 per generation
- Daily cost: $1.00
- Monthly cost: ~$30
With optimizations:
- Better caching (90% hit rate): $0.50/day, $15/month
- Model selection (GPT-3.5 vs GPT-4): 10x savings
- Batching (50% reduction): $0.25/day, $7.50/month
The difference between unoptimized and optimized is 4x cost reduction. For a team running 10,000 tests per day, that's $300/month vs $75/month.
🔗Observability: Tracking What Matters
You can't optimize what you can't measure. Track:
Cost metrics:
- Tokens consumed (input + output) per generation
- Cost per generation
- Daily/weekly/monthly spend
- Cost per test run
Efficiency metrics:
- Cache hit rate (target: >70%)
- Average generations per test
- Provider distribution (which models are being used)
- Batch utilization rate
Quality metrics:
- Generation success rate
- Schema validation pass rate
- Relationship consistency rate
Set up alerts for:
- Daily spend exceeding budget
- Cache hit rate dropping below 50%
- Unusual cost spikes
- Generation failures
🔗The Bottom Line
LLM-powered mock generation doesn't have to be expensive. With the right strategies—aggressive caching, deterministic seeding, provider selection, batching, and cost budgets—you can keep costs under $0.01 per 1,000 requests.
The key is treating it like any other resource: measure it, optimize it, and set limits. Don't let the "AI magic" make you forget that this is still a cost that needs to be managed.
For most teams, the cost of LLM-powered mocks is negligible compared to the value of more realistic test data. But without proper optimization, costs can spiral. These patterns ensure you get the benefits without the budget surprises.