Cost, latency, and limits
A demo that costs a few cents and takes ten seconds is fine. A product serving thousands of users is not. Production LLM engineering is constrained by three numbers — money, time, and rate limits — and all three are measured in tokens. This lesson teaches you to estimate and track cost, measure and reduce latency, stay inside rate limits, and log the data you need to make good decisions.
Definitions
Providers charge separately for input tokens (everything you send) and output tokens (everything generated), usually quoted in US dollars per one million tokens. Output tokens typically cost 3–5× more than input tokens.
The time a user waits. For LLMs it has two parts: time to first token (TTFT) — how long until output starts — and generation time — output tokens divided by the model's speed in tokens per second.
A cap your provider enforces on usage per time window, typically requests per minute (RPM) and tokens per minute (TPM), sometimes also per day. Exceeding it returns HTTP 429 Too Many Requests.
Calculating cost
The formula is simple: cost = input_tokens × input_price + output_tokens × output_price, with prices per million tokens. The prices below are illustrative — always use your provider's current pricing page.
PRICES_PER_MILLION = { # (input $, output $) - example numbers only
"small-fast-model": (0.15, 0.60),
"mid-model": (0.40, 1.60),
"frontier-model": (2.50, 10.00),
}
def call_cost(model: str, input_tokens: int, output_tokens: int) -> float:
price_in, price_out = PRICES_PER_MILLION[model]
return (input_tokens * price_in + output_tokens * price_out) / 1_000_000
# a RAG answer: 3,000 tokens of context + question, 400-token answer
for model in PRICES_PER_MILLION:
one = call_cost(model, 3_000, 400)
monthly = one * 20_000 * 30 # 20k requests per day
print(f"{model:<17} ${one:.5f} per call ${monthly:>9,.2f} per month")
The same feature costs roughly 17× more on the frontier model. Notice also that input dominates here: 3,000 input tokens outweigh 400 output tokens even at a 4× higher output price. In RAG systems, retrieving fewer, better chunks is often the biggest cost lever.
| Pricing detail | What it means |
|---|---|
| Cached input | Repeated prompt prefixes (same system prompt, same documents) can be billed at a large discount — often 50–90% off — when the provider's prompt cache hits. |
| Batch API | Submitting non-urgent jobs to a batch endpoint, completed within 24 hours, typically costs about 50% less. |
| Reasoning tokens | Reasoning models generate hidden “thinking” tokens that are billed as output tokens even though you do not see them. |
| Images, audio, tools | Images and audio are converted to tokens; tool definitions are part of the input on every call. |
Measuring latency
total latency ≈ network + queue + TTFT (prefill of input) + output_tokens / tokens_per_second
Stream the response to measure both parts. The first chunk that carries content marks the TTFT:
import time
from openai import OpenAI
client = OpenAI()
start = time.perf_counter()
first_token_at = None
pieces = []
stream = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[{"role": "user", "content": "Explain RAG in 80 words."}],
stream=True,
stream_options={"include_usage": True}, # final chunk carries token usage
)
usage = None
for chunk in stream:
if chunk.usage:
usage = chunk.usage
if chunk.choices and chunk.choices[0].delta.content:
if first_token_at is None:
first_token_at = time.perf_counter()
pieces.append(chunk.choices[0].delta.content)
end = time.perf_counter()
ttft = first_token_at - start
gen_time = end - first_token_at
print(f"TTFT: {ttft * 1000:.0f} ms")
print(f"total: {(end - start) * 1000:.0f} ms")
print(f"speed: {usage.completion_tokens / gen_time:.0f} tokens/s")
Your numbers will vary with model, region, load, and prompt size. Measure on your own traffic and watch the p95 (the slowest 5% of requests), not just the average — users notice the slow tail.
How to reduce latency
| Technique | Effect |
|---|---|
| Stream the response | Users see text after the TTFT instead of after the full generation — the single biggest perceived-speed win. |
| Ask for shorter output | Output tokens are generated sequentially; halving answer length roughly halves generation time. |
| Use a smaller, faster model | Small models generate many more tokens per second and have lower TTFT. |
| Shrink the prompt | Less prefill work; retrieve fewer chunks, trim history. |
| Keep prompt prefixes stable | Enables prompt caching, which lowers both cost and TTFT. |
| Run independent calls in parallel | Total time becomes the slowest call instead of the sum. |
Rate limits
Rate limits are set per account or project and usually grow automatically as you spend more. When you hit one, the API returns 429 and often tells you when to retry. Responses also include headers showing your remaining budget:
x-ratelimit-limit-requests: 5000
x-ratelimit-remaining-requests: 4987
x-ratelimit-limit-tokens: 2000000
x-ratelimit-remaining-tokens: 1964210
x-ratelimit-reset-tokens: 1.07s
| Strategy | How |
|---|---|
| Retry with exponential backoff | SDKs do this automatically (max_retries). Respect Retry-After. |
| Limit concurrency | Cap simultaneous requests with a worker pool or semaphore. |
| Reduce tokens per request | TPM is often the binding limit, not RPM. |
| Queue background work | Push non-urgent jobs through a queue or the Batch API. |
| Distinguish quota from speed | insufficient_quota means billing, not too many requests — retrying will not help. |
Controlled parallelism
Here is a pattern for processing many prompts concurrently while capping the number in flight. fake_call simulates a 0.2-second API call so you can run it offline.
import time
from concurrent.futures import ThreadPoolExecutor
def fake_call(prompt: str) -> str:
time.sleep(0.2) # stand-in for network + generation time
return prompt.upper()
prompts = [f"summarise doc {i}" for i in range(10)]
start = time.perf_counter()
sequential = [fake_call(p) for p in prompts]
print(f"sequential: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool: # at most 5 requests in flight
parallel = list(pool.map(fake_call, prompts))
print(f"parallel (5 workers): {time.perf_counter() - start:.1f}s")
print(parallel[:2], parallel == sequential)
pool.map returns results in the original order. Choose max_workers so that workers × tokens per request stays below your TPM limit.
Log every call
You cannot optimise what you do not measure. Record a structured log line for every request, then analyse it with pandas or your observability tool.
import json
import time
from dataclasses import asdict, dataclass
PRICES_PER_MILLION = {"small-fast-model": (0.15, 0.60)}
@dataclass
class LLMCallLog:
feature: str
model: str
input_tokens: int
output_tokens: int
latency_ms: float
finish_reason: str
cost_usd: float
def log_call(feature, model, usage, latency_s, finish_reason) -> LLMCallLog:
price_in, price_out = PRICES_PER_MILLION[model]
cost = (usage["prompt_tokens"] * price_in + usage["completion_tokens"] * price_out) / 1e6
entry = LLMCallLog(feature, model, usage["prompt_tokens"], usage["completion_tokens"],
round(latency_s * 1000, 1), finish_reason, round(cost, 6))
print(json.dumps(asdict(entry)))
return entry
t0 = time.perf_counter()
time.sleep(0.05) # pretend API call
fake_usage = {"prompt_tokens": 1200, "completion_tokens": 180}
entry = log_call("support-bot", "small-fast-model", fake_usage, time.perf_counter() - t0, "stop")
print(entry.cost_usd)
| Field | Why log it |
|---|---|
| Feature / endpoint name | Find which product feature drives cost. |
| Model and version | Compare models; detect silent upgrades. |
| Input / output / cached tokens | Cost and context-size trends. |
| Latency (TTFT and total) | Catch slowdowns and p95 regressions. |
| Finish reason and errors | Spot truncation, filtering, and failure rates. |
| User or tenant id (hashed) | Detect abuse and allocate spend. |
Be careful about logging full prompts and responses: they may contain personal data or secrets. Log token counts and metadata by default, and store content only with a clear retention policy and access controls.
Caching responses
Many applications see the same question repeatedly. Caching the answer for identical requests costs nothing and returns instantly:
import hashlib
import json
CACHE: dict[str, str] = {}
calls = {"api": 0}
def fake_llm(messages, model, temperature):
calls["api"] += 1
return f"answer to: {messages[-1]['content']}"
def cached_chat(messages, model="small-fast-model", temperature=0.0) -> str:
key_src = json.dumps({"m": messages, "model": model, "t": temperature}, sort_keys=True)
key = hashlib.sha256(key_src.encode()).hexdigest()
if key not in CACHE:
CACHE[key] = fake_llm(messages, model, temperature)
return CACHE[key]
q = [{"role": "user", "content": "What are your opening hours?"}]
for _ in range(3):
print(cached_chat(q))
print("API calls made:", calls["api"])
Only cache deterministic, non-personal requests (low temperature, no user-specific data). In production use Redis or a database with an expiry time instead of a Python dict. Semantic caching goes further by matching questions that are similar in meaning using embeddings.
Cost-reduction checklist
- Route by difficulty: send easy requests to a small model and only hard ones to a frontier model.
- Trim input: fewer retrieved chunks, summarised history, concise system prompts.
- Cap output: set
max_tokensand ask for concise answers. - Keep prefixes stable to benefit from prompt caching.
- Cache repeated answers.
- Batch offline jobs through the Batch API.
- Set budgets and alerts per project in the provider dashboard, plus your own per-user limits.
Other limits to know
| Limit | What to do |
|---|---|
| Context window | Count tokens; trim or summarise history; use retrieval. |
| Max output tokens | Split long generation into sections, or continue when finish_reason == "length". |
| Request timeout | Set client timeouts; stream long answers; use background jobs for very long tasks. |
| Payload size (files, images) | Resize images; upload large files through the provider's file API. |
| Content policies | Handle content_filter results and refusals gracefully in the UI. |
- Use
call_costto compare a 6,000-token RAG prompt with a 1,500-token one at 50,000 requests per day. - Wrap your real API helper with timing and the
log_callfunction, run ten prompts, and load the logs into a pandas DataFrame to get mean and p95 latency. - Add the response cache to your helper and measure how many calls it saves on a list of questions with duplicates.
- Cost = input tokens × input price + output tokens × output price; input often dominates in RAG.
- Latency = TTFT + output tokens ÷ speed; stream responses and keep answers short.
- Rate limits are RPM and TPM; back off on 429, cap concurrency, and distinguish quota from speed.
- Log model, tokens, latency, finish reason, and cost for every call.
- Cut spend with model routing, shorter prompts, prompt caching, response caching, and batching.
- Next: apply these fundamentals to writing reliable prompts in the Prompt Engineering course.
Where to go next
You have completed LLM Fundamentals. Continue with Prompt Engineering to turn this understanding into reliable prompts, then build grounded applications in RAG Systems.