Skip to main content
ANVISoftware Solutions
Lesson 21 of 22Advanced17 min

Cost and Latency

By the end of this lesson

Control token spend and keep responses fast enough to use.

Two numbers decide whether an AI feature survives contact with a finance review: what one request costs, and how long a person waits for it. Both are controllable, and both are usually left to whatever the first working version happened to do.

Cost comes from tokens, counted separately in each direction because they are priced differently. Output is commonly several times the input rate, so a hundred tokens of answer can cost more than a thousand tokens of prompt. Any cost work that treats a token as a token will aim at the wrong half.

Latency has a different shape. Input is processed largely in parallel, so a longer prompt adds modestly to the wait. Output is produced one token at a time, so the length of the answer is the dominant term. That is why a shorter answer is both the cheapest and the fastest change available to you.

Cost per request, logged per feature, with a budget alert
Python
# Placeholder rates in pence per million tokens, chosen to show the arithmetic.
# Real prices differ by model and change without warning. Read them from
# configuration and check the provider's published rates before forecasting.
RATES = {
    "gpt-4.1-mini": {"input": 10, "output": 40},
    "gpt-4.1": {"input": 150, "output": 600},
}
DAILY_BUDGET_PENCE = 50_000

def record_cost(model: str, usage, feature: str, caller_id: str) -> float:
    rate = RATES[model]
    pence = (
        usage.prompt_tokens * rate["input"] + usage.completion_tokens * rate["output"]
    ) / 1_000_000

    log.info(
        "model cost",
        extra={
            "feature": feature,                 # "policy_answer", "claim_summary", "eval_judge"
            "model": model,
            "input_tokens": usage.prompt_tokens,
            "output_tokens": usage.completion_tokens,
            "cached_input_tokens": getattr(usage, "cached_tokens", 0),
            "pence": round(pence, 4),
            "caller": caller_id,
        },
    )

    spent = daily_spend.add(pence)               # integer pence in a shared counter
    if spent > DAILY_BUDGET_PENCE and alerts.first_today("ai-budget"):
        alerts.send("AI daily budget exceeded", spent_pence=round(spent))
    return pence
  • Input and output are multiplied by separate rates, which is the whole reason this function exists rather than a single token count. Get this wrong and you will optimise the prompt while the answer length is what is costing you.
  • The feature field is what makes the log usable. An application-wide total tells you spend went up; a breakdown by feature tells you the evaluation judge is now two thirds of your bill, which is a different conversation and a much easier fix.
  • cached_input_tokens is recorded separately because providers bill cached input at a reduced rate. Without the field you cannot tell whether your caching is working — you will see a lower bill and be unable to attribute it. Field names for this differ between providers and have changed, so read the current response reference rather than trusting the attribute name here.
  • Cost per request is the number that scales. A monthly total tells you what happened; cost per request multiplied by expected volume tells you what happens when the feature is rolled out to the whole company, which is the question you will be asked.
  • The counter holds integer pence. Accumulating sub-penny floats over a million requests drifts, and if the total ever reaches a finance system it needs to be exact rather than close.
  • A budget alert is not a cap. It tells a person that spend crossed a line, which is the right first step. If you need spending to actually stop, that is a separate control: refuse new requests, drop to a cheaper model, or queue the work — and it needs deciding before the day it matters.

The levers, roughly in order of how much they return for the effort:

Cap the output
The highest-value single change, because output is the expensive direction and the slow one. Ask for the length you want in the prompt and enforce a maximum in the request. Then check the finish reason, because a cap that truncates answers is not a saving, it is a defect.
Send less input
Four well-chosen passages instead of ten. Four turns of history instead of the whole conversation. A system prompt someone has actually edited. These compound, because input is resent on every single request forever.
Cache the repeated work
In an internal assistant, colleagues ask the same questions. Cache the question embedding, the retrieval result and, with care, the whole answer. Providers also cache a repeated prompt prefix at a lower rate, which needs your stable content first and byte-identical.
Use a smaller model where it suffices
Summarising a passage you supplied, classifying a topic, extracting fields and scoring a judge prompt are all tasks where a small model is frequently indistinguishable on your own evaluation set. The way to know is to run the set on both, not to assume in either direction.
Do less work per request
One well-built call beats a chain of four. Every extra step is another prompt, another output, another wait, and in an agent loop the transcript is resent each turn. The cheapest token is the one a step you removed would have sent.
Caching and trimming, with the cache key that stops it serving stale answers
Python
from functools import lru_cache
import hashlib

MAX_PASSAGES = 4
MAX_HISTORY_TURNS = 4
MAX_ANSWER_TOKENS = 250
ANSWER_TTL_SECONDS = 6 * 3600

@lru_cache(maxsize=8192)
def embed_question(question: str) -> tuple[float, ...]:
    """Distinct questions repeat constantly in an internal tool. One call each."""
    return tuple(embed(question.strip().lower()))

def answer_cache_key(question: str, caller: User) -> str:
    material = "|".join(
        [
            CHAT_MODEL,                                  # a model change invalidates
            PROMPT_VERSION,                              # a prompt change invalidates
            INDEX_VERSION,                               # a re-ingest invalidates
            question.strip().lower(),
            ",".join(sorted(caller.audience_tags)),      # permissions change what is retrieved
        ]
    )
    return hashlib.sha256(material.encode()).hexdigest()

def build_messages(question: str, passages: list[Passage], history: list[dict]) -> list[dict]:
    # Stable content first and byte-identical, so a provider-side prompt cache can
    # reuse the prefix. Anything that varies per request goes last.
    return [
        {"role": "system", "content": SYSTEM},
        *history[-MAX_HISTORY_TURNS * 2 :],
        {
            "role": "user",
            "content": context_block(passages[:MAX_PASSAGES]) + f"\n\nQUESTION\n{question}",
        },
    ]

def cheap_answer(question: str, caller: User) -> Answer:
    key = answer_cache_key(question, caller)
    cached = answer_cache.get(key)
    if cached:
        metrics.increment("answer_cache_hit")
        return cached

    passages = search(embed_question(question), caller, top_k=MAX_PASSAGES)
    result = generate(
        build_messages(question, passages, load_history(caller)),
        max_completion_tokens=MAX_ANSWER_TOKENS,
    )
    if result.grounded:                                  # never cache an abstain or a failure
        answer_cache.set(key, result, ttl_seconds=ANSWER_TTL_SECONDS)
    return result
  • The cache key is where answer caching goes wrong, so it is worth reading slowly. Model, prompt version and index version are all in it, which means any change you make invalidates the cache automatically rather than serving answers produced by a system that no longer exists.
  • Audience tags are in the key because retrieval is permission-filtered. Leave them out and two callers with different permissions share a cached answer, which turns a performance optimisation into a disclosure. If a cache key omits anything that affects what was retrieved, it is not safe.
  • The time to live is short deliberately. Documents change, and a six-hour window bounds how long a superseded policy can be repeated. Pick the number from how often your documents actually change, and add an explicit invalidation when ingestion runs.
  • Only grounded answers are cached. Caching an abstain means a question stays unanswerable for six hours after somebody uploads the document that answers it, and caching a failure serves the failure repeatedly.
  • Provider-side prompt caching is a different mechanism and it has one requirement: the repeated part must come first and be identical byte for byte. Putting a timestamp or the user's name at the top of your system prompt defeats it completely, and that is a surprisingly common way to lose the discount.
  • The trimming is unremarkable and it is where most of the input saving actually comes from. Four passages, four turns, a cap on the answer. Every one of those numbers should be a named constant you can change and re-evaluate, not a literal buried in a function.

Model choice is where most of the money is decided, and the default is rarely examined:

 The largest model, by defaultA smaller model, evaluated
Cost per requestOften ten or more times higher, in both directionsThe reason the feature is affordable at company scale
LatencySlower to the first token and slower per tokenUsually a noticeably shorter wait for the same answer
Quality on a grounded taskBetter on the hardest cases, often indistinguishable on the restFrequently equal on summarising and extracting text you supplied
How you find outYou do not. Nothing tells you the spend was unnecessaryRun the evaluation set on both and compare per check
When it earns its placeMulti-step reasoning, ambiguous questions, code, long synthesisClassification, extraction, format work, judging, routine answers
Sensible arrangementOne model for everything, chosen once and never revisitedSmall model by default, large one for the cases you measured it on

Where the time goes in one answer from the knowledge assistant, and what each part responds to:

  1. Embedding the question — a model call

    A round trip to the provider before any search happens. Small and fast, and it is a network call, so it has a floor you cannot optimise below. Caching removes it entirely for a repeated question.

  2. Searching the store

    Usually the smallest part of the total, and it grows with index size, filtering and how many results you ask for. This is the one part that is ordinary database work with ordinary database fixes.

  3. Waiting for the first output token — a second model call

    This is the second sequential wait, and it cannot start until retrieval has finished. It grows with input size, which is one more reason four passages beat ten. Typically the largest single pause before anything appears on screen.

  4. Generating the rest of the answer

    Roughly proportional to the number of output tokens. A 400-word answer takes about four times as long to produce as a 100-word one. Shortening the answer is the most reliable latency fix there is.

  5. Your output checks

    Structure, citation resolution, the guardrail checks. These need the complete response, so they run after generation finishes and they are the reason a validated answer cannot be streamed straight through.

  6. Decide what the user sees while all that happens

    Streaming shows tokens as they are produced, so the first words appear in a fraction of a second. Total time is unchanged. It converts a blank wait into visible progress, which is worth a great deal and is not a speed improvement.

Summary

  • Count input and output tokens separately — output is priced higher and dominates how long a reply takes
  • Log cost per request per feature, alert on a budget, and remember an alert is not a cap
  • Cache embeddings, retrievals and answers, with model, prompt and index versions plus the caller's permissions in the key
  • The largest model is rarely necessary for grounded work and is often why a feature is uneconomic — measure a smaller one on your evaluation set
  • Retrieval and generation are two sequential model round trips, and streaming makes the wait visible without shortening it

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Price one question, then price the rollout

Add per-request cost logging to your assistant and use it for a day. Then work out: the median cost per question, the 95th percentile, and the monthly total if 400 colleagues each ask six questions on twenty working days.

Now halve it. Change one thing at a time — output cap, passage count, model — and re-run your evaluation set after each.

Show solution

The gap between the median and the 95th percentile is usually the surprise, and it is often several times. Look at what is up there: pasted text, long conversations, and answers that ran to the cap. Those requests are where a cheap fix exists, and an average would have hidden all of them.

The projection matters more than the day's total. Forty-eight thousand questions a month turns a figure that looked like nothing into a real line item, and that is the number a rollout decision is made against.

Of the three changes, the output cap normally returns the most for the least risk, because output is the expensive direction. Passage count is next and it is the one to watch on the evaluation set, since it trades cost against retrieval recall directly.

The model change is the big one and the one to do last, after the cheap changes have already reduced the baseline. Doing it first flatters it: some of the saving you attribute to the smaller model was available without changing models at all.

Why one at a time, again: with three changes and a 55 percent saving you cannot say which change bought it, and if the evaluation set regressed you cannot say which change cost you. The discipline is the same as the previous lessons, applied to a different number.

Think about it

Fast enough to use

Your assistant takes 4.5 seconds to answer: 0.3 embedding the question, 0.2 searching, 1.4 to the first output token, 2.4 generating 380 words, 0.2 on output checks. Users say it feels slow.

What would you change first, and what would streaming do for you here?

Show solution

Generation is 2.4 of the 4.5 seconds and it is proportional to the length of the answer. A 380-word reply to a policy question is longer than anyone needed, so shortening the answer is the first change: it cuts the largest term, reduces the expensive half of the bill, and usually makes the answer more useful to read.

The 1.4 seconds before the first token is the next target, and it responds to a smaller model and to less input. Six passages down to four takes something off it, and the work is already justified by cost.

Embedding and search together are half a second and they are sequential with everything else — this is the shape worth internalising: a retrieval assistant makes two separate round trips to a model and cannot start the second until the first has returned. Caching the embedding removes that 0.3 seconds entirely for a repeated question.

Streaming changes none of the 4.5 seconds. What it changes is that the user sees words at about 1.7 seconds instead of a blank panel until 4.5. For an answer of this length that is the difference between a tool people use and one they stop opening.

The catch to say out loud: the 0.2 seconds of output checks need the complete answer, so anything you validate cannot be streamed straight to the user. The usual compromise is to stream, then correct or withdraw if a check fails, and to accept that a withdrawal is visible. If your guardrails must hold before anything is shown, you cannot stream that answer — that is a real trade, not an implementation detail.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Your prompt is 3,000 tokens and the answer is 300. Where should you look first to reduce cost?
What does streaming the response achieve?

Saved in this browser only.