Most AI latency problems aren't infrastructure problems — they're prompt design problems. Learn how to architect prompts, model routing strategies, and caching layers that keep real-time AI applications under 2 seconds, even for complex multi-step tasks.

Picture this: your team has just shipped a customer-facing AI assistant that routes support tickets, suggests product recommendations, and summarizes conversation history for human agents. In testing, everything looked great. In production, the latency spikes to 8–12 seconds per response, and your Net Promoter Score tanks within a week. Customers expect a chatbot to respond in roughly the same time a competent human would glance at a question and type a quick reply — somewhere between 1 and 3 seconds. You've built something genuinely useful that nobody wants to use.
This is the latency trap. Engineers optimize for accuracy, completeness, and safety. They test with low-traffic sandboxes and fast internal connections. Then real users arrive with real expectations, and the system breaks not because it's wrong, but because it's slow. The frustrating part is that most of the latency doesn't live in your infrastructure — it lives in your prompts. The way you structure your instructions, how much context you pass, which model you invoke, and whether you've thought about streaming all have enormous impact on perceived and actual response time.
By the end of this lesson, you'll be able to design prompts that minimize time-to-first-token, reduce total generation time, and architect systems that feel fast even when they're doing complex work behind the scenes.
What you'll learn:
This is an expert-level lesson. You should already be comfortable with:
Before you can optimize, you need a precise mental model. LLM latency has two distinct components that behave very differently and respond to different interventions.
Time-to-first-token (TTFT) is the delay between when your request is sent and when the first token of the response arrives. This is dominated by:
Time-per-output-token (TPOT) is how long each subsequent token takes to generate. This is dominated by:
Your total wall-clock latency is roughly TTFT + (TPOT × output_token_count). This formula immediately tells you two important things. First, long prompts hurt TTFT. Second, long outputs hurt total latency far more than anything else — because every additional output token multiplies TPOT.
Key insight: Most developers obsess over reducing input prompt length, but output length is actually the bigger latency driver. A prompt that generates 500 tokens of output will almost always take longer than a prompt with twice the input tokens but 50 tokens of output.
To understand why input length affects TTFT, you need to understand how transformers process input. When your prompt is sent to the model, the entire input is processed through the attention mechanism before a single output token is produced. This is the "prefill" phase. Longer inputs mean more prefill work. On models with large context windows processing many-thousand-token prompts, prefill alone can account for 1–3 seconds of TTFT even before generation starts.
To understand how this maps to real product decisions, consider what Understanding Large Language Models: How ChatGPT and Claude Actually Work explains about the autoregressive generation process: each output token depends on all previous tokens, so you can't parallelize generation within a single response. You can parallelize across requests, but a single response is fundamentally sequential.
Before optimizing, measure. Too many teams run a handful of manual tests and eyeball the results. Here's a proper measurement harness:
import time
import asyncio
import statistics
from openai import AsyncOpenAI
client = AsyncOpenAI()
async def measure_latency(prompt: str, system: str, model: str, n_runs: int = 10) -> dict:
"""
Measures TTFT and total latency for a given prompt configuration.
Returns p50, p90, p99 for both metrics.
"""
ttft_samples = []
total_samples = []
for i in range(n_runs):
ttft = None
start = time.perf_counter()
stream = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": prompt}
],
stream=True,
max_tokens=150
)
async for chunk in stream:
if ttft is None and chunk.choices[0].delta.content:
ttft = time.perf_counter() - start
# consume stream to get total time
total_time = time.perf_counter() - start
ttft_samples.append(ttft)
total_samples.append(total_time)
# Small delay between runs to avoid rate limits
await asyncio.sleep(0.5)
def percentiles(samples):
sorted_s = sorted(samples)
n = len(sorted_s)
return {
"p50": sorted_s[n // 2],
"p90": sorted_s[int(n * 0.9)],
"p99": sorted_s[int(n * 0.99)] if n >= 100 else sorted_s[-1],
"mean": statistics.mean(sorted_s)
}
return {
"model": model,
"system_tokens_approx": len(system.split()) * 1.3,
"prompt_tokens_approx": len(prompt.split()) * 1.3,
"ttft": percentiles(ttft_samples),
"total": percentiles(total_samples)
}
Run this harness on your actual prompts, not toy examples. Production latency is what matters. Always measure p90 and p99, not just the mean — users experience worst-case latency, and averages hide the spikes that destroy UX.
Now let's get specific. Here are the prompt design patterns that most commonly introduce unnecessary latency, and what to do about each.
It's tempting to write exhaustive system prompts that cover every edge case, establish comprehensive persona, and load the model with background knowledge. Some system prompts in production systems run to 3,000–5,000 tokens. Every token in that system prompt adds to your prefill time on every single request.
The good news: most LLM providers cache the KV (key-value) state of repeated prompt prefixes. If your system prompt is identical across requests, the provider may only process it once and reuse the computation. OpenAI's prompt caching and Anthropic's prompt caching both work this way. But this only helps if your system prompt is stable — if you're injecting user-specific data into your system prompt, you defeat caching.
Here's the pattern that breaks caching:
# BAD: User data injected into system prompt defeats KV caching
system_prompt = f"""
You are a customer service agent for Meridian Healthcare.
The current user is {user.name}, account #{user.account_id}.
Their plan is {user.plan_type} with deductible ${user.deductible}.
Their last claim was filed on {user.last_claim_date}.
...
"""
And here's the pattern that preserves it:
# GOOD: Stable system prompt + user context in the user message
system_prompt = """
You are a customer service agent for Meridian Healthcare.
You will receive member information in structured format at the start of each conversation.
Respond helpfully and concisely. Focus on answering the specific question asked.
Keep responses under 3 sentences unless detail is explicitly requested.
"""
user_message = f"""
Member info: Name={user.name}, Plan={user.plan_type}, Deductible=${user.deductible},
Last claim={user.last_claim_date}, Account={user.account_id}
User question: {user_question}
"""
The system prompt stays identical across all users, enabling KV-cache hits. The user-specific data goes in the user message, which changes per request but is much smaller.
Warning: Prompt caching behavior varies significantly by provider and even by API tier. Check your provider's documentation explicitly — some caching only kicks in above a minimum token threshold (e.g., OpenAI's cache applies to prompts over 1,024 tokens). Test with your actual tier before designing around it.
Output format instructions are necessary, but many prompts include redundant, conflicting, or excessively detailed formatting guidance that both adds input tokens and — more importantly — causes the model to produce longer outputs.
Compare these two instruction sets:
Verbose format instruction (adds ~80 input tokens, generates ~400+ output tokens):
Please provide a comprehensive analysis of the situation. Include an executive summary
at the top, followed by a detailed breakdown of each factor. Use headers for each
section. Include a pros and cons list where relevant. Conclude with your recommendation
and the reasoning behind it. Make sure the response is thorough and covers all angles.
Tight format instruction (adds ~25 input tokens, generates ~80 output tokens):
Respond in this exact format:
ISSUE: [one sentence]
RECOMMENDATION: [one sentence]
CONFIDENCE: [High/Medium/Low]
The second version will be 5–10x faster to generate and still gives a downstream system everything it needs. For structured output use cases, explicit format templates like this are doubly valuable — they reduce latency AND improve parse reliability.
Chain-of-thought (CoT) reasoning dramatically improves accuracy for complex tasks, but it's a latency killer when applied indiscriminately. When you ask a model to "think step by step," you're instructing it to produce hundreds of tokens of intermediate reasoning before the actual answer. For a customer support routing decision, this is overkill.
The right pattern is to separate reasoning from response:
# For complex decisions where accuracy matters more than speed:
# Use a CoT prompt but only show the user the final answer
reasoning_prompt = """
Given this support ticket, determine the correct routing category.
Think through the issue carefully, then provide your final answer.
<ticket>
{ticket_content}
</ticket>
After your analysis, output exactly:
ROUTE: [category]
"""
# For high-volume, latency-sensitive classification:
# Skip CoT entirely
fast_prompt = """
Classify this support ticket into exactly one category: billing, technical, account, other.
Output only the category name.
Ticket: {ticket_content}
"""
Use CoT for tasks where a wrong answer is expensive (routing to the wrong escalation team, approving a transaction that shouldn't be approved). Skip CoT for tasks where you have high base-rate accuracy, can tolerate occasional errors, or where volume demands speed.
Tip: If you need both reasoning quality and low latency, consider a two-model architecture: a fast small model handles 80% of cases it's confident about, and a slower reasoning model handles the ambiguous 20%. This is the "model routing" pattern we'll cover later.
Let's drive this point home with concrete numbers. On a typical frontier model, TPOT runs approximately 15–30 milliseconds per output token. A response that's 500 tokens longer than necessary costs you 7.5–15 additional seconds of wall-clock time. That's not a prompt engineering detail — it's the difference between a usable product and an unusable one.
Here are the specific prompt techniques that control output length:
Every major LLM API exposes a max_tokens parameter. Use it aggressively. Most developers set this to an arbitrarily large value "just in case" — this is a mistake. Set it to the maximum you'd ever actually want, based on your use case.
# For a ticket classification endpoint:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
max_tokens=20, # Category name only — no more than 20 tokens needed
temperature=0
)
# For a summarization endpoint:
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
max_tokens=200, # ~150 words — enough for an executive summary
temperature=0.3
)
Setting max_tokens=20 for classification doesn't just save generation time — it also prevents the model from generating unsolicited explanations, which is a common failure mode.
The API limit is a hard ceiling. Your prompt instruction sets the expected behavior. Use both together:
Summarize this customer complaint in exactly 2 sentences. Do not add context,
caveats, or recommendations. Output only the 2-sentence summary.
Notice the three-part structure: specify the length, prohibit common verbosity additions, and tell it what to output only. Each part closes a different loophole the model might exploit to generate more text.
Models are trained to be helpful and thorough. Without negative constraints, they'll add caveats, offer alternatives, acknowledge uncertainty, and conclude with offers to help further. Each of these adds tokens:
Do not include:
- Explanations of your reasoning
- Caveats or disclaimers
- Offers to provide more detail
- Transition phrases like "Certainly!" or "Of course!"
Output only the answer.
This negative constraint list, at roughly 40 tokens, will save you hundreds of tokens in output on verbose models. It pays for itself on the first call.
Prompt design doesn't happen in isolation — it happens in the context of a model choice. Different models have dramatically different latency profiles, and the right architecture often involves routing different request types to different models.
Here's a rough latency + capability hierarchy (these numbers shift as providers update infrastructure, but the relative order is stable):
| Model Class | Typical TTFT | Typical TPOT | Best For |
|---|---|---|---|
| Small/fast (GPT-4o-mini, Claude Haiku, Gemini Flash) | 200–400ms | 10–20ms/token | Classification, extraction, simple Q&A |
| Mid-tier (GPT-4o, Claude Sonnet) | 400–800ms | 20–35ms/token | Summarization, moderate reasoning |
| Large/frontier (Claude Opus, o1) | 800ms–2s+ | 35–80ms/token | Complex reasoning, nuanced judgment |
A latency-aware architecture doesn't use the same model for everything. It routes tasks to the fastest model that can handle them reliably.
def route_to_model(task_type: str, complexity_score: float) -> str:
"""
Routes requests to appropriate model based on task type and estimated complexity.
complexity_score: 0.0 (simple) to 1.0 (complex), estimated from input features.
"""
# High-volume, low-complexity tasks always go to fast tier
if task_type in ["classify", "extract_entities", "route_ticket"]:
return "gpt-4o-mini"
# Summarization depends on complexity
if task_type == "summarize":
if complexity_score < 0.4:
return "gpt-4o-mini"
elif complexity_score < 0.8:
return "gpt-4o"
else:
return "gpt-4o" # Even complex summaries don't need frontier model
# Reasoning tasks use mid-tier by default, escalate only when needed
if task_type in ["recommend", "analyze", "draft"]:
if complexity_score > 0.85:
return "claude-opus-4"
return "claude-sonnet-4-5"
return "gpt-4o-mini" # Safe default
Estimating complexity before calling the model is itself a task — you can use cheap heuristics (input length, number of entities, domain of question) or even a very fast classification call to an ultra-cheap model.
Key insight: The savings from model routing are multiplicative. If you can correctly route 70% of your requests to a model that's 3× faster, your average latency drops dramatically — even though 30% of requests still hit the slower model. This is often a bigger win than any prompt optimization.
Actual latency and perceived latency are different problems. Users don't experience your P90 TTFT — they experience the moment something appears on their screen. Streaming is the single most effective tool for reducing perceived latency without changing actual generation speed.
When you use streaming, the first token appears as soon as it's generated — often 300–600ms after the request. The user sees the response "typing out" in real time. Even if the total generation time is 8 seconds, a response that starts appearing in 500ms feels dramatically faster than one that shows nothing for 8 seconds and then dumps text all at once.
Here's a production-ready streaming implementation pattern:
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
app = FastAPI()
client = AsyncOpenAI()
async def stream_ai_response(user_message: str, session_context: dict):
"""
Generator function that yields SSE-formatted chunks as they arrive.
"""
system_prompt = build_stable_system_prompt() # Stable for KV caching
messages = build_message_history(session_context, user_message)
stream = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "system", "content": system_prompt}] + messages,
stream=True,
max_tokens=300,
temperature=0.3
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
# SSE format: "data: {chunk}\n\n"
yield f"data: {delta}\n\n"
yield "data: [DONE]\n\n"
@app.post("/chat")
async def chat_endpoint(request: ChatRequest):
return StreamingResponse(
stream_ai_response(request.message, request.session_context),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no" # Disable Nginx buffering if applicable
}
)
Warning: Streaming creates complexity in your application layer. Your frontend needs to handle partial responses gracefully — you can't parse JSON from a streaming response until it's complete. If your AI output needs to be parsed before display (e.g., a structured JSON object), streaming the entire response isn't helpful. Consider streaming a status message or skeleton response while the actual computation happens in the background.
For UIs that need to render structured data, use a two-phase approach:
This isn't AI-specific trickery — it's the same pattern web apps use for loading states. But it's dramatically more effective with AI because users expect AI to "think" before responding.
The fastest API call is the one you don't make. Caching is underused in AI applications because teams assume every query is unique. In practice, many high-value production queries are highly repetitive — the same FAQ questions, the same classification decisions on similar inputs, the same summaries of the same documents.
There are three levels of caching to think about:
For deterministic use cases (classification, extraction from fixed documents), implement a lookup cache keyed on the exact prompt:
import hashlib
import json
from functools import lru_cache
import redis
cache = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_cache_key(model: str, messages: list, max_tokens: int) -> str:
"""Creates a deterministic cache key from request parameters."""
payload = json.dumps({
"model": model,
"messages": messages,
"max_tokens": max_tokens
}, sort_keys=True)
return f"llm:{hashlib.sha256(payload.encode()).hexdigest()}"
async def cached_completion(model: str, messages: list, max_tokens: int,
ttl_seconds: int = 3600) -> str:
"""
Checks cache before making an API call.
Uses TTL to ensure cached responses don't become stale.
"""
cache_key = get_cache_key(model, messages, max_tokens)
# Check cache first
cached_response = cache.get(cache_key)
if cached_response:
return json.loads(cached_response)
# Cache miss — make the actual API call
response = await client.chat.completions.create(
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=0 # Only cache deterministic outputs (temp=0)
)
result = response.choices[0].message.content
# Store in cache with TTL
cache.setex(cache_key, ttl_seconds, json.dumps(result))
return result
Note the temperature=0 constraint — exact-match caching only makes sense for deterministic completions. If you cache stochastic outputs, you're effectively forcing a single response for every matching query, which may not be what you want for conversational AI.
For conversational use cases where queries vary in phrasing but not intent, exact-match caching has low hit rates. Semantic caching uses embedding similarity to find "close enough" previous responses. This is a rich topic in itself — Semantic Caching and Vector Search for LLM Applications covers the architecture in detail, including how to set similarity thresholds that balance cache hit rate against response quality.
The core pattern:
async def semantic_cached_completion(query: str, similarity_threshold: float = 0.92) -> str:
"""
Uses embedding similarity to find cached responses to semantically similar queries.
Falls back to live inference on cache miss.
"""
query_embedding = await embed_text(query)
# Search vector store for similar previous queries
similar_results = vector_store.search(
query_embedding,
top_k=1,
score_threshold=similarity_threshold
)
if similar_results:
cached_response, similarity_score = similar_results[0]
log_cache_hit(query, similarity_score)
return cached_response
# Cache miss — generate and store
response = await live_completion(query)
vector_store.upsert(query_embedding, query, response)
return response
The 0.92 threshold here is a starting point — you'll tune it based on your domain. FAQ-style queries can tolerate higher similarity thresholds (0.95+) because the same question phrased differently genuinely deserves the same answer. More nuanced queries may need lower thresholds.
If you know certain requests will arrive at predictable times or can be triggered by upstream events, pre-compute the AI response before the user asks:
# Example: Pre-compute daily briefings for sales reps at 7am
# so they're available instantly when reps start their day at 8am
async def precompute_sales_briefings(territory_ids: list[str]):
"""
Runs at 7am daily. Generates AI briefings for all territories
and caches them for fast retrieval throughout the day.
"""
tasks = []
for territory_id in territory_ids:
territory_data = await fetch_territory_data(territory_id)
prompt = build_briefing_prompt(territory_data)
tasks.append(generate_and_cache_briefing(territory_id, prompt))
# Generate all briefings concurrently
await asyncio.gather(*tasks)
This pattern converts a user-blocking operation into a background operation. The user experience is "instant" because the work happened before they asked.
Sometimes you can't reduce the information you need to send — but you can represent that information more efficiently.
Verbose natural-language context can often be replaced with dense structured notation:
# Verbose (312 tokens):
The customer, John Martinez, has been with us since March 2019. He is currently
on our Professional plan which costs $299 per month. Last month he filed a support
ticket (#TK-8847) about API rate limiting which was resolved by our team. He has
contacted support 3 times in the past 90 days. His current contract expires on
December 31, 2025. He has 12 active seats on his account.
# Compressed (67 tokens):
Customer: John Martinez | Tenure: 6yr | Plan: Professional ($299/mo) |
Seats: 12 | Contract-exp: 2025-12-31 | Support-90d: 3 tickets |
Last-ticket: TK-8847 (API rate limiting, resolved)
The compressed version contains identical information at 21% of the token count. Models handle this structured notation well — they're trained on enormous amounts of structured data in similar formats.
Tip: Before writing a custom compression scheme, check whether your use case fits a standard format like YAML or key-value pairs. These are often more readable for prompt debugging and more reliably parsed by models than ad-hoc notation.
In multi-turn conversations, context accumulates rapidly. By turn 10 of a support conversation, you may have 2,000+ tokens of history that's largely irrelevant to the current question. Rather than passing full history, use a summarization buffer.
This is closely related to memory management patterns described in LLM Memory Architecture for Enterprise Applications. The latency-aware version maintains a "rolling summary" that compresses old turns:
class ConversationBuffer:
def __init__(self, max_tokens: int = 800, summary_threshold: int = 600):
self.messages = []
self.summary = ""
self.max_tokens = max_tokens
self.summary_threshold = summary_threshold
def add_message(self, role: str, content: str):
self.messages.append({"role": role, "content": content})
# If we're approaching the token limit, summarize older messages
if self.estimate_tokens() > self.summary_threshold:
asyncio.create_task(self._summarize_old_messages())
async def _summarize_old_messages(self):
"""Compresses the oldest 60% of messages into a summary."""
cutoff = len(self.messages) * 4 // 10 # Keep newest 40%
to_summarize = self.messages[:cutoff]
self.messages = self.messages[cutoff:]
summary_prompt = f"""Summarize this conversation segment in 3-4 sentences,
preserving key facts, decisions, and unresolved issues:
{format_messages(to_summarize)}
"""
# Use fast model for summarization — this is infrastructure, not customer-facing
new_summary = await fast_completion(summary_prompt, max_tokens=100)
self.summary = (self.summary + " " + new_summary).strip()
def build_context(self) -> list:
"""Returns messages for the API call, with summary as context if present."""
context = []
if self.summary:
context.append({
"role": "user",
"content": f"[Prior conversation summary: {self.summary}]"
})
context.extend(self.messages)
return context
Prompt chaining is essential for complex AI workflows, but each hop in a chain adds latency. A 3-step pipeline where each step takes 2 seconds gives you 6 seconds total — which may be acceptable for batch processing but is unusable for real-time interaction.
The key is identifying which steps are sequential (each requires the previous step's output) and which are parallel (independent of each other).
Before implementing a pipeline, draw the dependency graph:
# Sequential — each step depends on the previous:
User query → Intent classification → Context retrieval → Response generation
# Parallelizable — these steps are independent:
User query → [Intent classification, Entity extraction, Sentiment analysis] → Response generation
# Mixed:
User query → Intent classification → [Response draft, Compliance check] → Final response
Steps that are independent of each other can run concurrently:
async def analyze_support_ticket(ticket_text: str) -> dict:
"""
Runs multiple analysis tasks concurrently on the same input,
then combines results for the response generation step.
"""
# These three analyses are independent — run them in parallel
classification_task = asyncio.create_task(
classify_ticket_type(ticket_text)
)
sentiment_task = asyncio.create_task(
analyze_customer_sentiment(ticket_text)
)
entity_task = asyncio.create_task(
extract_key_entities(ticket_text) # Product, version, error codes
)
# Wait for all three to complete
ticket_type, sentiment, entities = await asyncio.gather(
classification_task, sentiment_task, entity_task
)
# Now generate the routing recommendation using all three inputs
# This step is sequential because it depends on the above
routing = await generate_routing_recommendation(
ticket_type, sentiment, entities, ticket_text
)
return {
"routing": routing,
"ticket_type": ticket_type,
"sentiment": sentiment,
"entities": entities
}
If each subtask takes 1.5 seconds, the sequential version takes 4.5 seconds plus the routing step. The parallel version takes 1.5 seconds (the slowest parallel task) plus the routing step — a 3× improvement just from restructuring the execution order.
Warning: Parallel API calls multiply your cost and can trigger rate limits faster. Monitor your rate limit consumption when moving from sequential to parallel execution. You may need to implement backoff logic or request batching to stay within provider limits.
For cases where you can predict what the response will likely look like before you have all the information, you can start generating immediately and update as more context arrives. This is advanced territory, but here's the concept:
async def speculative_support_response(ticket_text: str) -> AsyncIterator[str]:
"""
Starts generating a generic acknowledgment while running classification,
then switches to a specific response once classification is complete.
This exploits the fact that all support responses start with an acknowledgment.
"""
# Start classification immediately (runs in background)
classification_task = asyncio.create_task(classify_ticket(ticket_text))
# Immediately start generating the acknowledgment (this will be valid regardless)
acknowledgment_prompt = f"""
Write only the opening sentence of a support response to this ticket.
The sentence should acknowledge the customer's issue empathetically.
Ticket: {ticket_text[:200]}
"""
# Stream the acknowledgment while classification runs
async for chunk in stream_completion(acknowledgment_prompt, max_tokens=40):
yield chunk
# By now, classification should be complete (or nearly so)
ticket_type = await classification_task
# Continue with the substantive response using classification context
substantive_prompt = build_substantive_prompt(ticket_text, ticket_type)
async for chunk in stream_completion(substantive_prompt, max_tokens=200):
yield chunk
This is aggressive — it assumes the acknowledgment will always be valid. In practice, you need to verify this assumption holds for your use case before shipping it.
You're building a real-time AI triage system for a B2B SaaS company's support queue. The system needs to:
Current implementation: one sequential GPT-4o call with a 1,200-token system prompt, generating 300–400 tokens of output. Average latency is 9 seconds. This is unacceptable — the goal is under 2 seconds.
Your task: Redesign this system using the techniques from this lesson.
Step 1: Baseline measurement First, measure your current implementation using the harness from the "Understanding Where Latency Actually Lives" section. Record p50, p90, and p99 for both TTFT and total latency.
Step 2: Separate the tasks Notice that classification, entity extraction, urgency scoring, and summary generation are all independent of each other. Design a parallel execution plan.
Step 3: Apply model routing
max_tokens of 10–20.max_tokens=60.Step 4: Compress the system prompt Rewrite the 1,200-token system prompt to under 150 tokens. Remember: stable system prompt, dynamic context in user message.
Step 5: Add max_tokens constraints
Add hard token limits to every call:
max_tokens=10max_tokens=20max_tokens=5max_tokens=60Step 6: Implement parallel execution
Use asyncio.gather() to run all four tasks concurrently.
Step 7: Measure again Run the measurement harness on your redesigned implementation. Compare the numbers.
Expected outcome: With parallel execution + model routing + output constraints, you should achieve under 1.5 seconds p90 total latency for the complete triage response — a 6× improvement from the baseline.
Bonus challenge: Add a Redis cache for exact-match lookups. Run the measurement again and observe what happens to repeat-ticket latency.
Two likely causes:
You optimized the wrong bottleneck. Input token reduction only helps TTFT. If your TPOT × output_tokens term dominates (likely if your outputs are long), input compression helps little. Measure TTFT and total latency separately to know which to attack.
You're not hitting the bottleneck you think. Network latency to the provider, provider-side queueing, or rate limit throttling may be the actual culprit. Add timing instrumentation at the network level. If TTFT is 3+ seconds on a short prompt, the issue is upstream of your prompt design.
Parallel API calls can hit rate limits faster, causing 429 errors and retry delays. Implement exponential backoff:
import asyncio
import random
async def api_call_with_retry(prompt: str, max_retries: int = 3) -> str:
for attempt in range(max_retries):
try:
return await make_api_call(prompt)
except RateLimitError:
if attempt == max_retries - 1:
raise
# Exponential backoff with jitter
wait_time = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(wait_time)
This is a frontend issue, not an AI issue. Ensure you're appending chunks rather than replacing the entire response on each chunk. If you're using React, use a ref-based approach to avoid unnecessary re-renders on each token.
For exact-match caches: check whether you have any dynamic elements (timestamps, session IDs, random seeds) in your prompt that prevent matches. Even a user ID embedded in the prompt defeats exact matching.
For semantic caches: your similarity threshold may be too high. Lower it in 0.01 increments and measure the quality/hit-rate tradeoff. You can also log cache hits and misses to analyze which query types are caching well and which aren't.
Model routing only works when the fast model is actually capable enough for the task. If you're routing classification to a small model and accuracy is suffering, you have three options:
Note: When evaluating model performance for routing decisions, always evaluate on your actual data, not model benchmarks. General benchmarks are poor predictors of performance on specific domain tasks. Build a small labeled dataset from your production data and evaluate all candidate models against it before making routing decisions.
This is almost always a load issue. Under load, provider-side queueing increases, and your TTFT grows. A few things to check:
You can track this over time with the evaluation frameworks discussed in Designing AI Evaluation Frameworks: How to Benchmark, Test, and Monitor LLM Performance in Production Workflows. Latency regression is as important to track as accuracy regression.
Latency-aware prompt design isn't a single technique — it's a discipline that spans prompt structure, model selection, system architecture, and frontend rendering. Here's what we covered:
The fundamentals:
Prompt-level optimizations:
Architecture-level optimizations:
asyncio.gather()The mindset shift: Stop thinking of your prompt as a document and start thinking of it as a performance-critical API contract. Every token has a cost in latency and money. Every output instruction is a latency budget decision. The best prompt for a real-time application is often not the most thorough prompt — it's the most precise one.
Where to go from here:
If you're managing the cost side of this equation alongside the speed side, Cost Optimization for AI API Usage is the natural companion to this lesson — latency and cost are driven by the same underlying variables (tokens and model tier), so optimizing one usually improves the other.
For teams building multi-step autonomous AI systems where latency compounds across many tool calls and decisions, the patterns in Agentic AI Workflows: Designing Multi-Step Autonomous Pipelines That Plan, Act, and Self-Correct become essential — that's where prompt-level latency decisions interact with orchestration-level architecture decisions.
Finally, if you're deploying any of these systems in customer-facing contexts, review Embedding AI Guardrails in Production Workflows — latency optimizations like aggressive output constraints and model routing can sometimes create new failure modes that need guardrails to catch. Speed and safety need to be engineered together, not sequentially.
Intro to AI & Prompt Engineering