Skip to content

Prompt Engineering at Scale: From One-Off Prompts to Managed Prompt Systems

7 min read1209 words

The first prompt you write for a production system is a string in your code. The fiftieth prompt is a liability if it changes without testing, if it diverges between environments, if you can't roll back a bad version, if you can't tell which prompt produced which output in your traces.

Prompt engineering at scale is not about writing better prompts. It's about treating prompts as production artifacts with the same rigor you'd apply to any other production code.

This post covers the infrastructure I've built and used for managing prompts across complex LLM systems.

The Problems That Emerge at Scale

When you have one or two prompts, none of this matters much. When you have 20+ prompts across a production system, these problems become real:

Prompt drift across environments. Development, staging, and production have diverged because someone edited the prompt in prod to fix a quick issue and forgot to update the repo.

No rollback capability. A prompt change degraded output quality. You want to roll back, but the previous version lives only in git history, not as a deployable artifact.

Can't tell which prompt version produced what output. Your observability traces show LLM calls, but you can't tell if the bad output came from prompt v3 or v4.

No systematic testing before deploy. Prompt changes are "tested" by eyeballing a few examples, then deployed to all users simultaneously.

Hard to A/B test. You want to validate that a new prompt performs better, but the infrastructure to split traffic and compare outcomes doesn't exist.

Each of these is solvable with a small amount of infrastructure built early.

The Prompt Registry Pattern

The foundation: all prompts live in a central registry, versioned, named, and retrievable at runtime.

interface PromptVersion {
  id: string
  name: string
  version: string        // Semantic versioning: "1.2.0"
  content: string        // The prompt text
  variables: string[]    // Required template variables
  model: string          // Intended model
  maxTokens: number
  temperature: number
  tags: string[]
  createdAt: Date
  createdBy: string
  isActive: boolean
  isTested: boolean
  testResults?: PromptTestResults
}
 
class PromptRegistry {
  async get(name: string, version?: string): Promise<PromptVersion> {
    if (version) {
      return this.db.prompts.findOne({ name, version })
    }
 
    // Default to active version
    return this.db.prompts.findOne({ name, isActive: true })
  }
 
  async render(
    name: string,
    variables: Record<string, string>,
    version?: string
  ): Promise<string> {
    const prompt = await this.get(name, version)
 
    // Validate all required variables are provided
    const missingVars = prompt.variables.filter(v => !(v in variables))
    if (missingVars.length > 0) {
      throw new Error(`Missing variables for prompt ${name}: ${missingVars.join(', ')}`)
    }
 
    // Template substitution
    return prompt.content.replace(
      /\{\{(\w+)\}\}/g,
      (_, key) => variables[key] ?? `{{${key}}}`
    )
  }
}

Usage in application code:

const systemPrompt = await promptRegistry.render('agent-system-prompt', {
  userRole: user.role,
  timezone: user.timezone,
  tools: enabledTools.join(', '),
})

The application code never has a raw prompt string. It has a prompt name and variables. The actual text lives in the registry.

Prompt Testing Before Promotion

Before a new prompt version is marked as active (deployable to production), it must pass a test suite. The test suite is a set of input/expected-output pairs that define acceptable behavior for the prompt.

interface PromptTest {
  id: string
  promptName: string
  input: {
    variables: Record<string, string>
    messages?: Message[]
  }
  expectedBehavior: {
    contains?: string[]        // Output should contain these strings
    notContains?: string[]     // Output should not contain these
    matchesSchema?: z.ZodSchema  // Output should match this schema
    customEvaluator?: string   // LLM-as-judge prompt for semantic evaluation
  }
  tags: string[]
}
 
async function runPromptTests(
  promptName: string,
  version: string,
  tests: PromptTest[]
): Promise<PromptTestResults> {
  const results = await Promise.allSettled(
    tests.map(test => runSingleTest(promptName, version, test))
  )
 
  const passed = results.filter(r => r.status === 'fulfilled' && r.value.passed).length
  const failed = results.length - passed
 
  return {
    promptName,
    version,
    totalTests: tests.length,
    passed,
    failed,
    passRate: passed / results.length,
    failures: results
      .filter(r => r.status === 'fulfilled' && !r.value.passed)
      .map(r => (r as PromiseFulfilledResult<TestResult>).value),
  }
}

A prompt version can only be promoted to active if its pass rate exceeds a configured threshold (usually 90%+ for production).

⚠️Tests Are Only as Good as the Test Cases

A prompt can pass all its tests and still behave badly in production if the tests don't cover the actual distribution of inputs. Start with tests based on real production inputs, not idealized examples. Add new tests whenever a production failure occurs.

Environment-Based Prompt Resolution

Different environments should use different prompt versions explicitly, not by accident.

interface EnvironmentConfig {
  environment: 'development' | 'staging' | 'production'
  promptOverrides: Record<string, string>  // name -> version to use
}
 
const environmentConfig: EnvironmentConfig = {
  environment: process.env.NODE_ENV as 'development' | 'staging' | 'production',
  promptOverrides: JSON.parse(process.env.PROMPT_OVERRIDES ?? '{}'),
}
 
async function getPromptForEnvironment(name: string): Promise<PromptVersion> {
  // Explicit override for this environment takes precedence
  const overrideVersion = environmentConfig.promptOverrides[name]
  if (overrideVersion) {
    return promptRegistry.get(name, overrideVersion)
  }
 
  // Active version for production, latest tested for staging, latest for dev
  switch (environmentConfig.environment) {
    case 'production': return promptRegistry.get(name) // active version
    case 'staging': return promptRegistry.getLatestTested(name)
    case 'development': return promptRegistry.getLatest(name)
  }
}

This means production always uses explicitly promoted, tested versions. Staging gets the latest tested candidate. Development gets the latest draft.

A/B Testing Prompts

To validate a new prompt actually performs better before full rollout, you need traffic splitting with outcome tracking.

interface PromptExperiment {
  id: string
  promptName: string
  controlVersion: string    // Currently active version
  treatmentVersion: string  // New version to test
  trafficSplit: number      // 0-1, fraction going to treatment
  startDate: Date
  endDate: Date
  metric: 'user_satisfaction' | 'task_completion' | 'cost_per_task' | 'latency'
}
 
async function getPromptForExperiment(
  name: string,
  userId: string
): Promise<{ prompt: PromptVersion; variant: 'control' | 'treatment' }> {
  const experiment = await getActiveExperiment(name)
 
  if (!experiment) {
    return { prompt: await promptRegistry.get(name), variant: 'control' }
  }
 
  // Consistent assignment per user (same user always gets same variant)
  const hash = hashString(`${userId}-${experiment.id}`)
  const isInTreatment = (hash % 100) < (experiment.trafficSplit * 100)
 
  const version = isInTreatment
    ? experiment.treatmentVersion
    : experiment.controlVersion
 
  return {
    prompt: await promptRegistry.get(name, version),
    variant: isInTreatment ? 'treatment' : 'control',
  }
}

Track the variant in your observability traces alongside the outcome metric. After sufficient sample size, compare treatment vs control on your metric to make the promotion decision with data.

Connecting Prompts to Traces

Every LLM call in your observability traces should record which prompt name and version it used. This is the link that lets you debug: "which prompt version produced these bad outputs?"

async function callLLMWithPrompt(
  promptName: string,
  variables: Record<string, string>,
  additionalMessages: Message[],
  trace: LangfuseTrace
): Promise<string> {
  const promptVersion = await getPromptForEnvironment(promptName)
  const renderedPrompt = promptVersion.content.replace(...)
 
  const generation = trace.generation({
    name: promptName,
    model: promptVersion.model,
    promptName,         // LangFuse can use this to link to your prompt registry
    promptVersion: promptVersion.version,
    input: [{ role: 'system', content: renderedPrompt }, ...additionalMessages],
  })
 
  // ... execute and return
}

Now when you look at a trace in LangFuse, you can see: this call used agent-system-prompt version 2.1.0. If version 2.1.0 had a bug, you can filter your traces to see all affected calls.


Prompt management infrastructure is not exciting to build. It's operational plumbing. But when you're managing 50 prompts across a production system, the alternative scattered strings, no versioning, no testing, no rollback is a maintenance nightmare that degrades over time.

Build the registry. Version the prompts. Write the tests. Run them before promoting. Your future self will thank you.