LLMOps — Operating Large Language Models in Production
LLMOps extends standard DevOps/MLOps to cover the specific challenges of LLM-based systems: RAG pipelines, observability beyond metrics, cost control, and guardrails.
RAG Architecture
RAG (Retrieval-Augmented Generation) prevents hallucinations by grounding LLM responses in retrieved context. The LLM doesn't need to know everything — it reasons over retrieved facts.
graph LR
USER["User query:<br/>'What caused the<br/>production outage?'"] --> EMBED["Embed query<br/>text-embedding-3-small<br/>--> float32[1536]"]
EMBED --> VDB["Vector DB<br/>cosine similarity search<br/>return top-5 chunks"]
VDB --> CONTEXT["Retrieved context:<br/>5 runbook/log chunks<br/>most similar to query"]
CONTEXT --> PROMPT["Augmented prompt:<br/>Context: [chunks]<br/>Question: [query]"]
PROMPT --> LLM["LLM<br/>GPT-4 / Llama-3<br/>generates grounded answer"]
LLM --> ANSWER["Answer grounded<br/>in actual docs"]
Indexing Pipeline (offline)
graph LR
DOCS["Source docs<br/>(runbooks, Confluence, tickets)"] --> CHUNK["Chunker<br/>512 tokens, 50 overlap"]
CHUNK --> EMBED2["Embedding model<br/>text-embedding-3-small"]
EMBED2 --> STORE["Vector DB<br/>INSERT (content, embedding, metadata)"]
# Chunking with overlap preserves context across chunk boundaries
def chunk_document(text: str, size: int = 512, overlap: int = 50) -> list[str]:
tokens = tokenizer.encode(text)
chunks = []
for i in range(0, len(tokens), size - overlap):
chunk = tokens[i:i + size]
chunks.append(tokenizer.decode(chunk))
return chunks
Vector Databases
| DB | Deployment | Best for | K8s-native |
|---|---|---|---|
| pgvector | PostgreSQL extension | Existing Postgres, small-medium scale | ✅ (any Postgres) |
| Milvus | Standalone cluster | High scale, dedicated vector search | ✅ (Helm chart) |
| Weaviate | Standalone cluster | Built-in text vectorization | ✅ (Helm chart) |
| Qdrant | Standalone | Rust-based, fast, simple API | ✅ (Helm chart) |
| Pinecone | Managed SaaS | No ops, pay-per-use | External |
| OpenSearch | Standalone cluster | Existing OpenSearch users | ✅ |
pgvector (K8s — start here)
# Postgres with pgvector extension
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-pgvector
spec:
template:
spec:
containers:
- name: postgres
image: pgvector/pgvector:pg16 # pgvector pre-installed
env:
- name: POSTGRES_DB
value: vectordb
-- Enable extension and create table
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
source TEXT,
embedding vector(1536), -- OpenAI text-embedding-3-small dimension
created_at TIMESTAMPTZ DEFAULT now()
);
-- IVFFlat index for approximate nearest neighbour (fast at scale)
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100); -- sqrt(row_count) is a good starting point
-- Query: find top-5 most similar chunks
SELECT content, source,
1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS similarity
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 5;
Milvus (for scale)
helm repo add milvus https://zilliztech.github.io/milvus-helm/
helm install milvus milvus/milvus \
--namespace milvus --create-namespace \
--set cluster.enabled=false \ # standalone mode for dev
--set persistence.enabled=true
LLM Tracing with LangSmith / OpenLLMetry
Standard OTel traces show HTTP calls. LLM traces show prompt chains — which prompts ran, what tokens were used, where latency came from.
OpenLLMetry (open-source, OTel-native)
Instruments LangChain, OpenAI, Anthropic, etc. Exports to your existing OTel Collector → Jaeger/Tempo.
from opentelemetry.sdk.trace import TracerProvider
from traceloop.sdk import Traceloop
# Initialize — auto-instruments all LLM calls
Traceloop.init(
app_name="rag-service",
exporter=OTLPExporter(endpoint="http://otel-collector:4317"),
)
# All downstream OpenAI/LangChain calls now emit spans automatically
Spans emitted per LLM call:
Span: llm.openai.chat
Attributes:
llm.model: gpt-4
llm.request.max_tokens: 1024
llm.usage.prompt_tokens: 342
llm.usage.completion_tokens: 128
llm.usage.total_tokens: 470
llm.request.temperature: 0.7
Duration: 1.4s
Events:
- name: "first_token", timestamp: +0.8s ← TTFT
LangSmith (LangChain-specific)
import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls_..."
os.environ["LANGCHAIN_PROJECT"] = "production-rag"
# All LangChain calls automatically traced to LangSmith
chain = retriever | prompt | llm | output_parser
result = chain.invoke({"question": "What is the RTO?"})
# Trace visible at langsmith.com with full prompt, retrieved docs, LLM output
Key LLM Metrics to Track
TTFT (Time To First Token) → user-perceived latency start
TPOT (Time Per Output Token) → generation speed (tokens/sec)
Total latency → TTFT + TPOT × output_tokens
Token cost → (prompt_tokens × $X + completion_tokens × $Y)
Context utilization → prompt_tokens / max_context_tokens (avoid truncation)
Retrieval score → cosine similarity of top-1 chunk (< 0.7 = poor retrieval)
# Average TTFT over 5 min
histogram_quantile(0.95, rate(llm_time_to_first_token_seconds_bucket[5m]))
# Token cost per minute
rate(llm_usage_total_tokens_total[1m]) * 0.00003 # $0.03 per 1K tokens (GPT-4)
Guardrails
Guardrails validate LLM inputs and outputs before they reach users or downstream systems.
graph LR
INPUT["User input"] --> INPUT_GUARD["Input guardrails<br/>- PII detection<br/>- prompt injection<br/>- topic filter"]
INPUT_GUARD -->|pass| LLM["LLM"]
LLM --> OUTPUT_GUARD["Output guardrails<br/>- hallucination check<br/>- toxicity filter<br/>- JSON schema validation"]
OUTPUT_GUARD -->|pass| USER["User"]
INPUT_GUARD -->|fail| REJECT1["Reject with reason"]
OUTPUT_GUARD -->|fail| REJECT2["Retry or fallback"]
NeMo Guardrails (NVIDIA, open-source)
from nemoguardrails import RailsConfig, LLMRails
config = RailsConfig.from_path("./config/guardrails/")
rails = LLMRails(config)
# config/guardrails/config.yml defines:
# - allowed topics
# - blocked topics (competitor mentions, PII)
# - output validation rules
response = await rails.generate_async(
messages=[{"role": "user", "content": user_input}]
)
Practical Output Validation
import json
from pydantic import BaseModel, validator
class StructuredOutput(BaseModel):
action: str
confidence: float
reasoning: str
@validator("confidence")
def confidence_in_range(cls, v):
assert 0.0 <= v <= 1.0
return v
def validated_llm_call(prompt: str) -> StructuredOutput:
response = llm.invoke(prompt)
try:
data = json.loads(response.content)
return StructuredOutput(**data)
except (json.JSONDecodeError, ValidationError) as e:
# Retry with corrective prompt or return safe default
raise LLMOutputValidationError(str(e))
Cost Optimization
| Technique | Savings | Tradeoff |
|---|---|---|
| Prompt caching (OpenAI/Anthropic) | 50–90% on repeated context | Cache only stable prefixes |
| Smaller model for simple queries | 10–50× cost reduction | Accuracy may drop |
| Quantization (fp16→int4) | 2–4× GPU memory reduction | 1–3% accuracy loss |
| KV cache sharing (vLLM prefix caching) | Reduce TTFT for common system prompts | Memory tradeoff |
| Batching requests | Amortize GPU setup cost | Added latency |
| Spot/preemptible instances for training | 60–80% compute cost reduction | Need checkpoint/resume |
# Enable vLLM prefix caching — shared system prompt reuses KV cache
vllm serve llama-3-8b \
--enable-prefix-caching \ # cache KV for repeated prefixes
--max-num-seqs 256 # max concurrent sequences
Data Drift and Model Observability
For self-hosted fine-tuned models (not API calls), you need to detect when the model degrades.
| Signal | What it detects | Tool |
|---|---|---|
| Data drift | Input distribution changed from training data | Evidently, Whylogs |
| Concept drift | Same inputs, different correct outputs (world changed) | Human evaluation + shadow scoring |
| Output drift | Model outputs becoming shorter/longer/more generic | Prometheus histograms on output length, sentiment |
| Retrieval drift | Vector DB chunks no longer relevant (docs outdated) | Monitor similarity scores < threshold |
# Simple output drift detection: monitor response length distribution
from prometheus_client import Histogram
llm_response_tokens = Histogram(
"llm_response_tokens",
"Distribution of LLM response token counts",
buckets=[10, 50, 100, 200, 500, 1000]
)
def generate(prompt: str) -> str:
response = llm.invoke(prompt)
llm_response_tokens.observe(count_tokens(response))
return response
# Alert if p50 response length drops >30% (model becoming terse/degraded)
LLMOps Runbook: RAG Quality Drops
Symptom: Users report wrong or irrelevant answers. Retrieval similarity scores trending down.
1. Check retrieval similarity scores:
SELECT AVG(1 - (embedding <=> query_embedding)) FROM retrieval_logs
WHERE created_at > NOW() - INTERVAL '24h';
→ If < 0.70: embedding model or chunking problem
2. Check if source documents were updated:
SELECT MAX(updated_at) FROM documents;
→ Re-index if documents changed
3. Check LLM output in LangSmith/Phoenix traces:
→ Are retrieved chunks actually relevant to the question?
→ Is the LLM ignoring the context?
4. Check token count — context truncation:
→ If prompt_tokens ≈ max_context: documents are being cut off
→ Fix: reduce chunk size, reduce top-k, increase model context
5. Shadow scoring: run 100 queries through old and new pipeline:
→ Compare LLM-as-judge scores (GPT-4 rates quality 1-5)