LLM Application Architecture: Beyond the API Call

Design production LLM applications that are reliable, cost-efficient, and maintainable. Covers prompt engineering patterns, model routing, caching strategies, evaluation frameworks, and the operational patterns for running LLMs at scale.

LLM application architecture extends beyond the basic POST /v1/completions call by orchestrating multiple LLM components—prompt engineering, caching, batching, state management, error recovery, and asynchronous execution—into a cohesive system. This architecture matters when a single API call becomes a bottleneck, when latency exceeds user tolerance, when prompt quality drifts over time, or when a single model cannot handle the full scope of a user’s intent. It is essential for production-grade applications that serve thousands of concurrent users, process complex workflows, or maintain consistent, high-fidelity interactions across sessions.

Prompt Engineering and Template Management

Prompt templates define the interface between application logic and LLM behavior. Templates must be versioned, tested, and parameterized. A typical prompt is stored as a JSON file, with embedded Jinja2-like syntax for dynamic content.

{
  "name": "summarize-document",
  "version": "v2.1",
  "prompt": "You are an expert editor. Summarize the following document in {{ max_sentences }} sentences.\n\n---\n\n{{ content }}\n\n---\n\nOutput only the summary, no markdown, no preamble.",
  "parameters": {
    "max_sentences": {
      "type": "integer",
      "default": 3,
      "description": "Number of sentences in the final summary"
    },
    "tone": {
      "type": "string",
      "enum": ["concise", "detailed", "academic", "journalistic"],
      "default": "concise"
    }
  }
}

Templates are loaded at runtime using a PromptLoader service that resolves a template by name and version. The loader accepts a template_id and parameters, and returns a rendered prompt string.

prompt = prompt_loader.load(
    template_id="summarize-document",
    version="v2.1",
    parameters={"max_sentences": 5, "tone": "academic"}
)

The rendering process fails silently if a required parameter is missing, but logs a structured error:

{
  "error": "MissingRequiredParameter",
  "template_id": "summarize-document",
  "version": "v2.1",
  "missing": ["max_sentences"],
  "context": {
    "request_id": "req-7a2b8c",
    "timestamp": "2024-03-15T12:34:56Z"
  }
}

The system also supports fallbacks: if a template version is missing, it uses the latest stable version. If no template exists, it falls back to a default template defined in code.

Caching and Cache Invalidation

Caching is essential for reducing LLM call frequency and latency. The system uses a two-tier cache: in-memory (Redis) and on-disk (local filesystem or S3).

Each cache entry is identified by a cache_key, derived from the input parameters and the template signature:

cache_key = f"llm:{template_id}:{version}:{hash(parameters)}"

The hash function computes a SHA-256 digest of the parameters, ensuring that small changes in input yield distinct cache keys.

Cache operations are performed via a CacheManager service with the following interface:

result = cache_manager.get(cache_key)
if not result:
    result = llm_client.generate(prompt)
    cache_manager.set(cache_key, result, ttl=3600)  # 1 hour

The cache layer handles cache misses, cache hits, and TTL expiration. On cache miss, the system generates the LLM output and stores it with a configurable TTL.

A common failure mode is cache stampede: multiple concurrent requests for the same cache_key when the cache is cold. The system resolves this using a distributed cache lock mechanism with Redis:

lock_key = f"cache:lock:{cache_key}"
acquired = redis.set(lock_key, "1", nx=True, ex=30)  # EX: expire in 30s

if acquired:
    # This thread will generate the result
    result = llm_client.generate(prompt)
    cache_manager.set(cache_key, result, ttl=3600)
    redis.delete(lock_key)  # Release lock
else:
    # Wait for the result to be generated by another thread
    while not redis.exists(cache_key):
        time.sleep(0.1)
    result = cache_manager.get(cache_key)

Cache invalidation must be explicit. A new version of a template triggers a cache purge:

curl -X POST http://cache-service:8080/invalidate \
     -H "Content-Type: application/json" \
     -d '{"template_id": "summarize-document", "version": "v2.1"}'

The system also supports warm-up jobs that pre-populate the cache with common inputs, reducing cold-start latency.

Batch Processing and Queueing

For high-throughput applications, LLM calls are batched to reduce per-call overhead. The system uses a message queue (e.g., RabbitMQ or Kafka) to collect LLM requests, then processes them in batches.

Each batch is a JSON array of requests:

[
  {
    "request_id": "req-1",
    "template_id": "summarize-document",
    "version": "v2.1",
    "parameters": {
      "max_sentences": 4,
      "tone": "journalistic"
    },
    "input": "The company reported record revenue..."
  },
  {
    "request_id": "req-2",
    "template_id": "classify-text",
    "version": "v1.0",
    "parameters": {
      "label_set": ["news", "marketing", "technical"]
    },
    "input": "The new AI model reduces inference latency by 40%."
  }
]

A worker process consumes from the queue, groups requests into batches of size N, and sends them to the LLM API using a batch_generate endpoint:

POST /v1/completions/batch
Content-Type: application/json

[
  {
    "prompt": "You are an expert editor. Summarize the following document in 4 sentences.\n\n---\n\nThe company reported record revenue...",
    "max_tokens": 256
  },
  {
    "prompt": "Classify the following text into one of: news, marketing, technical.\n\n---\n\nThe new AI model reduces inference latency by 40%.",
    "max_tokens": 128
  }
]

The response is a list of results, preserving the order of requests:

[
  {
    "request_id": "req-1",
    "result": "The company achieved record revenue in Q4, driven by strong cloud product adoption and expansion into Europe.",
    "metadata": {
      "model": "gpt-4-turbo",
      "input_tokens": 128,
      "output_tokens": 32,
      "latency_ms": 240
    }
  },
  {
    "request_id": "req-2",
    "result": "technical",
    "metadata": {
      "model": "gpt-4-turbo",
      "input_tokens": 64,
      "output_tokens": 12,
      "latency_ms": 190
    }
  }
]

Batching introduces new failure modes. If a request fails during batch processing, the system logs the error and retries the entire batch with exponential backoff.

Asynchronous Execution and Task Orchestration

When LLM processing takes longer than a user expects, the system returns a task_id immediately and performs the work asynchronously.

POST /api/v1/tasks
Content-Type: application/json

{
  "template_id": "generate-report",
  "parameters": {
    "sections": ["executive-summary", "market-analysis", "financials"],
    "tone": "academic"
  },
  "input": "Data from Q1 2024..."
}

The response includes:

{
  "task_id": "task-9f8e7d",
  "status": "queued",
  "created_at": "2024-03-15T12:34:56Z",
  "result_url": "/api/v1/tasks/task-9f8e7d/result"
}

A background worker polls a task queue (e.g., via SELECT * FROM tasks WHERE status = 'queued' FOR UPDATE SKIP LOCKED) and processes each task:

  1. Load the template and render the prompt.
  2. Call the LLM API, storing the request_id and input in a Task table.
  3. If the LLM call fails, retry up to three times with exponential backoff.
  4. On success, update the task with status: "completed" and result.
  5. If the LLM call times out, start a fallback task using a smaller model.

The task system supports polling, webhook, and long-polling for result retrieval:

GET /api/v1/tasks/task-9f8e7d/result?wait=30

If wait=30, the server holds the connection open for up to 30 seconds, returning the result as soon as it’s ready.

Tasks are persisted in a PostgreSQL database with the following schema:

CREATE TABLE tasks (
    task_id TEXT PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN ('queued', 'processing', 'completed', 'failed')),
    input JSONB,
    parameters JSONB,
    result JSONB,
    error TEXT,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    model TEXT,
    input_tokens INT,
    output_tokens INT,
    latency_ms INT
);

A common failure is task loss: a worker crashes before updating a task. The system handles this via idempotent task processing: each task can be processed multiple times without side effects. The task_id is used as a unique identifier, and updates are idempotent through UPDATE ... WHERE task_id = ?.

Error Handling and Retry Strategies

LLM calls fail for many reasons: network timeouts, model unavailability, input validation errors, and model-specific issues. The system must handle these failures with a layered strategy.

Each LLM call includes a retry configuration:

{
  "max_retries": 3,
  "backoff": {
    "type": "exponential",
    "initial_delay_ms": 100,
    "multiplier": 2,
    "max_delay_ms": 3000
  },
  "retry_on": [
    "timeout",
    "5xx",
    "408",
    "429",
    "model_not_available"
  ],
  "timeout_ms": 5000
}

The system retries on HTTP 408 (Request Timeout), 429 (Too Many Requests), and any 5xx status code. It also recognizes a custom error code model_not_available, returned when the LLM model is not loaded.

On retry, the system logs the attempt number, the error, and the current state:

{
  "attempt": 2,
  "error": "TimeoutError",
  "message": "Request timed out after 5000ms",
  "details": {
    "request_id": "req-1234",
    "model": "gpt-4-turbo",
    "input_tokens": 1024,
    "request_body": "..."
  }
}

If all retries fail, the task is marked failed, and the error is stored in the error column of the tasks table.

The system also supports circuit breaking: if a model fails consecutively for 10 minutes, the system marks it as "unavailable" and redirects all future calls to a fallback model.

Configuration and Deployment

The system is configured using a single app.yaml file, with no vendor-specific keys:

llm:
  client:
    endpoint: "https://api.openai.com/v1/completions"
    api_key: "sk-abc123xyz"
    timeout_ms: 5000
    max_retries: 3
    backoff:
      type: "exponential"
      initial_delay_ms: 100
      multiplier: 2
      max_delay_ms: 3000
  cache:
    type: "redis"
    host: "redis:6379"
    db: 0
    ttl_seconds: 3600
    warmup:
      - template_id: "summarize-document"
        version: "v2.1"
        parameters: { max_sentences: 3 }
      - template_id: "classify-text"
        version: "v1.0"
        parameters: { label_set: ["news", "marketing"] }
  batching:
    enabled: true
    batch_size: 16
    max_wait_ms: 200
    queue: "llm-requests"
  asynchronous:
    task_timeout_ms: 60000
    result_ttl_hours: 72
    webhook_url: "https://webhook.example.com/llm-result"

Deployment uses standard tooling: docker-compose.yml, systemd services, and kubectl for Kubernetes.


services:
  llm-app:
    image: ai-llm-app:latest
    ports:
      - "8080:8080"
    volumes:
      - ./config/app.yaml:/app/config/app.yaml
    depends_on:
      - redis
      - queue
    environment:
      - LOG_LEVEL=info
      - TASK_QUEUE=llm-requests

The application starts with a health check:

GET /health

Response:

{
  "status": "healthy",
  "details": {
    "llm_client": "connected",
    "cache": "redis://redis:6379",
    "queue": "rabbitmq://queue:5672/llm-requests",
    "tasks": {
      "total": 127,
      "in_progress": 4,
      "failed": 2
    }
  }
}

Failure Modes and Observability

The system logs detailed traces for each LLM call using request_id as a correlation ID. Logs include:

A typical error is prompt template mismatch: the parameters field is misaligned with the expected keys. The system logs:

{
  "error": "TemplateParameterMismatch",
  "template_id": "summarize-document",
  "expected": ["max_sentences", "tone"],
  "received": ["max_sentences", "tone", "word_count"],
  "unknown": ["word_count"],
  "context": {
    "request_id": "req-abc123",
    "timestamp": "2024-03-15T12:34:56Z"
  }
}

Another edge case: model drift. The LLM output changes over time due to updates in the model or prompt. The system captures a baseline and compares new outputs using a simple cosine similarity between embeddings of the first 100 tokens.

Finally, cache warming is not automatic. The system requires a scheduled job (cron or systemd timer) to load common templates into the cache:

0 8 * * * /app/scripts/cache-warmup.sh

The script reads cache-warmup.json and populates the cache with pre-rendered prompts.

This architecture transforms the LLM from a black-box API call into a first-class application component—predictable, observable, and resilient.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.