GenAI MLOps: The Missing Manual for LLMs in Production - AI Engineering Hub

← Back to Home GenAI MLOps: The Missing Manual for LLMs in Production Operationalizing LLMs: prompt versioning, model routing, cost optimization, guardrails, observability, CI/CD for prompts, and governance. Table of Contents Prompt Versioning & Management Model Routing & Fallback Cost Optimization Guardrails & Safety Observability for LLMs CI/CD for Prompts Governance & Compliance Deployment Patterns Prompt Versioning & Management Prompts are code. Version them, test them, and deploy them like any other artifact. Prompt Registry from dataclasses import dataclass, field from datetime import datetime from typing import Optional import hashlib import json @dataclass class PromptVersion: id: str name: str version: str # semver: major.minor.patch template: str variables: list[str] model: str parameters: dict # temperature, max_tokens, etc. description: str author: str created_at: datetime parent_version: Optional[str] = None tags: list[str] = field(default_factory=list) metrics: dict = field(default_factory=dict) # eval results hash: str = "" def __post_init__(self): content = f"{self.template}:{json.dumps(self.parameters, sort_keys=True)}" self.hash = hashlib.sha256(content.encode()).hexdigest()[:12] class PromptRegistry: def __init__(self, storage_backend): self.storage = storage_backend # Git, DB, S3, etc. def register(self, prompt: PromptVersion) -> str: key = f"prompts/{prompt.name}/{prompt.version}.json" self.storage.put(key, prompt) # Also update "latest" pointer self.storage.put(f"prompts/{prompt.name}/latest.json", prompt.version) return prompt.id def get(self, name: str, version: str = "latest") -> PromptVersion: if version == "latest": version = self.storage.get(f"prompts/{name}/latest.json") return self.storage.get(f"prompts/{name}/{version}.json") def list_versions(self, name: str) -> list[str]: return self.storage.list(f"prompts/{name}/") # Usage registry = PromptRegistry(GitStorage("github.com/org/prompts")) prompt = PromptVersion( id="rag-qa-v1.2.0", name="rag-qa", version="1.2.0", template="""Answer using ONLY the context below. Context: {context} Question: {question} Answer:""", variables=["context", "question"], model="gpt-4o-mini", parameters={"temperature": 0.1, "max_tokens": 500}, description="RAG QA with strict grounding", author="engineer@company.com", created_at=datetime.now(), parent_version="1.1.0", tags=["rag", "qa", "production"], ) registry.register(prompt) Prompt Templating with Jinja2 from jinja2 import Environment, BaseLoader, StrictUndefined class PromptTemplate: def __init__(self, template_str: str): self.env = Environment(loader=BaseLoader(), undefined=StrictUndefined) self.template = self.env.from_string(template_str) def render(self, **kwargs) -> str: return self.template.render(**kwargs) # Advanced: conditional sections, loops, includes template_str = """ {% if system_prompt %}{{ system_prompt }}{% endif %} {% for example in few_shot_examples %} Example {{ loop.index }}: Input: {{ example.input }} Output: {{ example.output }} {% endfor %} Task: {{ task }} {% if constraints %}Constraints: {{ constraints }}{% endif %} """ template = PromptTemplate(template_str) rendered = template.render( system_prompt="You are a helpful assistant.", few_shot_examples=[{"input": "2+2", "output": "4"}], task="What is 3+3?", constraints="Answer in one word.", ) Prompt Testing in CI # tests/test_prompts.py import pytest from prompt_registry import PromptRegistry registry = PromptRegistry(LocalStorage("./prompts")) @pytest.mark.parametrize("prompt_name", ["rag-qa", "summarize", "code-gen"]) def test_prompt_renders(prompt_name): prompt = registry.get(prompt_name) # Test with sample variables sample_vars = get_sample_vars(prompt_name) rendered = PromptTemplate(prompt.template).render(**sample_vars) assert len(rendered) > 0 assert all(var in rendered for var in sample_vars.values()) @pytest.mark.parametrize("prompt_name", ["rag-qa"]) async def test_prompt_quality(prompt_name, judge_llm): prompt = registry.get(prompt_name) test_cases = load_test_cases(prompt_name) for tc in test_cases: rendered = PromptTemplate(prompt.template).render(**tc["input"]) response = await llm.generate(rendered, **prompt.parameters) score = await judge_llm.score(tc["input"], response) assert score["accuracy"] >= 4, f"Accuracy too low: {score}" assert score["instruction_following"] >= 4 Model Routing & Fallback Route requests to the optimal model based on task, cost, latency, and quality requirements. Intelligent Router from dataclasses import dataclass from enum import Enum from typing import Optional class ModelTier(Enum): PREMIUM = "premium" # GPT-4o, Claude 3.5 Sonnet STANDARD = "standard" # GPT-4o-mini, Claude 3 Haiku ECONOMY = "economy" # Local models, small open models @dataclass class ModelConfig: name: str tier: ModelTier cost_per_1m_input: float cost_per_1m_output: float max_tokens: int avg_latency_ms: int capabilities: list[str] # ["reasoning", "coding", "long_context", "vision"] MODEL_CATALOG = { "gpt-4o": ModelConfig("gpt-4o", ModelTier.PREMIUM, 5.00, 15.00, 128000, 2000, ["reasoning", "coding", "vision"]), "gpt-4o-mini": ModelConfig("gpt-4o-mini", ModelTier.STANDARD, 0.15, 0.60, 128000, 800, ["reasoning", "coding"]), "claude-3.5-sonnet": ModelConfig("claude-3.5-sonnet", ModelTier.PREMIUM, 3.00, 15.00, 200000, 2500, ["reasoning", "coding", "long_context"]), "claude-3-haiku": ModelConfig("claude-3-haiku", ModelTier.STANDARD, 0.25, 1.25, 200000, 600, ["coding", "speed"]), "llama-3.1-70b": ModelConfig("llama-3.1-70b", ModelTier.STANDARD, 0.80, 0.80, 128000, 1500, ["reasoning", "coding", "open"]), "llama-3.1-8b": ModelConfig("llama-3.1-8b", ModelTier.ECONOMY, 0.10, 0.10, 128000, 400, ["speed", "open"]), } class ModelRouter: def __init__(self, catalog=MODEL_CATALOG): self.catalog = catalog self.performance_history = {} def select_model(self, task: TaskRequest) -> str: """Select best model for task.""" candidates = self._filter_candidates(task) if not candidates: return "gpt-4o-mini" # default # Score by: quality (historical), cost, latency scored = [] for model_name in candidates: config = self.catalog[model_name] quality = self._get_quality_score(model_name, task.type) cost_score = 1 / (config.cost_per_1m_input + config.cost_per_1m_output) latency_score = 1 / config.avg_latency_ms # Weighted score score = 0.5 * quality + 0.3 * cost_score + 0.2 * latency_score scored.append((model_name, score)) scored.sort(key=lambda x: -x[1]) return scored[0][0] def _filter_candidates(self, task: TaskRequest) -> list[str]: candidates = [] for name, config in self.catalog.items(): # Check capability requirements if task.required_capabilities: if not all(cap in config.capabilities for cap in task.required_capabilities): continue # Check context window if task.estimated_tokens > config.max_tokens * 0.8: continue # Check budget if task.max_cost and config.cost_per_1m_input > task.max_cost: continue candidates.append(name) return candidates @dataclass class TaskRequest: type: str # "rag", "coding", "reasoning", "chat", "summarization" required_capabilities: list[str] = field(default_factory=list) estimated_tokens: int = 1000 max_cost: Optional[float] = None priority: str = "balanced" # "quality", "speed", "cost", "balanced" Fallback Chain class FallbackChain: def __init__(self, primary: str, fallbacks: list[str]): self.chain = [primary] + fallbacks self.current_index = 0 async def execute(self, request, executor) -> any: last_error = None for i, model in enumerate(self.chain): try: self.current_index = i return await executor(model, request) except Exception as e: last_error = e logger.warning(f"Model {model} failed: {e}, trying fallback") continue raise AllModelsFailed(f"All models failed. Last error: {last_error}") # Usage chain = FallbackChain( primary="gpt-4o", fallbacks=["claude-3.5-sonnet", "gpt-4o-mini", "llama-3.1-70b", "llama-3.1-8b"] ) result = await chain.execute(request, lambda model, req: llm_client.generate(model, req)) Cost Optimization Token Budgeting class TokenBudget: def __init__(self, daily_budget_usd: float, model_pricing: dict): self.daily_budget = daily_budget_usd self.pricing = model_pricing self.spent_today = 0.0 self.reset_time = datetime.now().replace(hour=0, minute=0, second=0) + timedelta(days=1) def can_afford(self, model: str, estimated_input: int, estimated_output: int) -> bool: config = MODEL_CATALOG[model] cost = (estimated_input * config.cost_per_1m_input + estimated_output * config.cost_per_1m_output) / 1_000_000 return self.spent_today + cost <= self.daily_budget def record_usage(self, model: str, input_tokens: int, output_tokens: int): config = MODEL_CATALOG[model] cost = (input_tokens * config.cost_per_1m_input + output_tokens * config.cost_per_1m_output) / 1_000_000 self.spent_today += cost # Check daily reset if datetime.now() >= self.reset_time: self.spent_today = 0 self.reset_time += timedelta(days=1) def get_remaining_budget(self) -> float: return max(0, self.daily_budget - self.spent_today) # Prompt compression def compress_prompt(prompt: str, target_tokens: int, compressor_model: str = "gpt-4o-mini") -> str: """Use a smaller model to compress prompt while preserving meaning.""" compression_prompt = f"""Compress this prompt to ~{target_tokens} tokens while preserving all key instructions and context: {prompt} Compressed version:""" return llm.generate(compression_prompt, model=compressor_model, max_tokens=target_tokens) Caching Strategies import redis import hashlib import json class LLMCache: def __init__(self, redis_client, ttl=3600): self.redis = redis_client self.ttl = ttl def _cache_key(self, model: str, prompt: str, params: dict) -> str: content = f"{model}:{prompt}:{json.dumps(params, sort_keys=True)}" return f"llm_cache:{hashlib.sha256(content.encode()).hexdigest()}" def get(self, model: str, prompt: str, params: dict) -> Optional[str]: key = self._cache_key(model, prompt, params) cached = self.redis.get(key) if cached: return json.loads(cached) return None def set(self, model: str, prompt: str, params: dict, response: str): key = self._cache_key(model, prompt, params) self.redis.setex(key, self.ttl, json.dumps(response)) # Semantic caching: cache based on embedding similarity def semantic_get(self, model: str, prompt: str, params: dict, threshold=0.95) -> Optional[str]: prompt_embedding = embed(prompt) # Search for similar cached prompts similar = vector_db.search(prompt_embedding, filter={"model": model}, k=5) for item in similar: if item.score >= threshold: return item.payload["response"] return None cache = LLMCache(redis_client) async def cached_generate(model, prompt, params, cache=cache): # Check exact cache cached = cache.get(model, prompt, params) if cached: return cached # Check semantic cache cached = cache.semantic_get(model, prompt, params) if cached: return cached # Generate response = await llm_client.generate(model, prompt, **params) # Store in both caches cache.set(model, prompt, params, response) vector_db.upsert(embed(prompt), {"model": model, "prompt": prompt, "response": response, "params": params}) return response Guardrails & Safety Input/Output Guards from guardrails import Guard from guardrails.hub import ( DetectPII, CompetitorCheck, ToxicLanguage, ProfanityFree, RegexMatch, ValidJson, RestrictToTopic, DetectJailbreak ) # Input guards input_guard = Guard().use_many( DetectPII(["EMAIL", "PHONE", "SSN", "CREDIT_CARD"], on_fail="filter"), DetectJailbreak(on_fail="exception"), RestrictToTopic(valid_topics=["technical", "general", "coding"], on_fail="filter"), ToxicLanguage(threshold=0.5, on_fail="exception"), ) # Output guards output_guard = Guard().use_many( DetectPII(["EMAIL", "PHONE", "SSN"], on_fail="filter"), ProfanityFree(on_fail="filter"), ValidJson(on_fail="reask"), # For structured output CompetitorCheck(competitors=["competitor1", "competitor2"], on_fail="filter"), ) async def guarded_generation(prompt, model, params, input_guard=input_guard, output_guard=output_guard): # Validate input validated_prompt = input_guard(prompt) # Generate response = await llm_client.generate(model, validated_prompt, **params) # Validate output validated_response = output_guard(response) return validated_response Custom Guardrails from guardrails import Validator, register_validator @register_validator(name="no-fabricated-citations", data_type="string") class NoFabricatedCitations(Validator): """Ensure all citations in response exist in provided context.""" def __init__(self, context_docs: list[str], on_fail="filter"): self.context_docs = context_docs self.on_fail = on_fail def validate(self, value: str) -> ValidationResult: # Extract citations like [doc_1], [doc_2] import re citations = re.findall(r'\[([^\]]+)\]', value) valid = True for cite in citations: found = any(cite in doc for doc in self.context_docs) if n

Comments

Popular posts from this blog

Firmware Testing Strategies: Hardware-in-the-Loop, Simulation, and CI/CD Pipelines | Embedded Systems Hub

IoT Protocols Compared: MQTT, CoAP, HTTP/2, WebSocket - Performance, Overhead, and Use Cases | Embedded Systems Hub

Embedded Security: Secure Boot, ARM TrustZone, and OTA Update Integrity | Embedded Systems Hub