Skip to content
Back to Blog
7 min read

Latency Budgets as a Cost Control: Pricing Each AI User Journey

Most AI applications have one latency target, usually “as fast as possible”, and one deployment configuration that serves every request. That is how a nightly summarisation job ends up running on the same provisioned capacity as the customer chat, and how a back-office classifier ends up on a reasoning model with default effort. Latency is not free on Azure OpenAI: every step towards faster or more predictable responses has a price, so a latency target is also a spending decision.

My position is that latency budgets should be set per user journey, written down, and used to choose the deployment tier, the model and the reasoning effort. The budget is what tells you where you are allowed to be cheap. Without it, teams default to the expensive option everywhere because nobody can prove the cheaper one is acceptable.

For the token-level side of this (which tokens slow a request and which only cost money), see where LLM latency goes. This post sits one level up: which journeys deserve fast, predictable capacity at all.

What a latency budget per journey looks like

A user journey is a path a person or system takes that ends in an outcome: “agent drafts a reply to a support ticket”, “analyst asks a question of the sales semantic model”, “invoices are classified overnight”. Each one has a different tolerance for waiting, and that tolerance is a product decision, not an engineering one.

A useful budget has four parts:

  • Who is waiting. A customer in a chat window, an employee in an internal tool, or nobody.
  • Which clock matters. Time to first token for anything streamed to a person; total time for anything a downstream system waits on.
  • The percentile. I’d budget on p95, not the average. Users remember the slow responses, and averages hide queueing on a busy deployment.
  • What happens when the budget is missed. Degrade to a simpler answer, fall back to another deployment, or simply wait. This is the part teams skip, and it decides whether you need reserved capacity at all.

The last point matters because “must never be slow” and “should usually be fast” lead to very different bills. Only the first needs reserved or premium capacity.

The tiers you are actually choosing between

On Azure OpenAI in Microsoft Foundry, the deployment types give you a ladder of latency behaviour and cost. As of April 2026, the options that matter for this decision are:

OptionLatency behaviourHow you payFits journeys that
Global BatchAsynchronous, 24-hour target turnaroundAround 50% less than Global Standard, separate enqueued quotaNobody waits on; results needed within a day
Global Standard / Data Zone StandardGood on average, varies with shared loadPay per tokenMost internal tools and tolerant user journeys
Priority processingLower, more consistent latency on pay-as-you-goPremium per-token rateCustomer-facing, spiky, can’t justify reserved capacity
Provisioned throughput (PTU)Predictable when utilisation stays under capacityPer PTU per hour, used or not; reservations lower the rateSteady, high-volume, latency-sensitive traffic

Global Batch is the cheapest way to run anything that doesn’t need an answer now, and its quota is separate from your online deployments, so batch work doesn’t eat into the headroom your interactive journeys rely on. Microsoft announced priority processing as generally available on 23 March 2026 (some docs still label it preview). Priority processing is available on Global Standard and Data Zone Standard (US) deployments of gpt-4.1, gpt-5.1, gpt-5.2 and gpt-5.4, using API version 2025-12-01 or later (the v1 endpoint qualifies), and you opt in per deployment or per request with service_tier.

Priority is a request, not a guarantee. The service can serve a priority request at the standard tier when your priority traffic ramps by more than 50% in under 15 minutes or during peak demand, and on gpt-4.1 and gpt-5.4 requests estimated at over 128k prompt tokens are always downgraded. Those requests are billed at the standard rate and come back with service_tier set to default in the response. For a latency budget that matters: log the tier the service actually used, per journey, or you won’t know whether a breached p95 came from your prompt or from a downgrade. Provisioned throughput is the one to be careful with: it only pays off when utilisation is high and steady, because idle PTUs cost the same as busy ones.

Data residency can narrow these choices before cost does. If a journey’s data must stay in a geography, Global deployment types may be off the table, and the comparison becomes Data Zone or regional options only. Settle that first.

Mapping journeys to tiers

Here is how I’d map a typical set of journeys onto the tiers from the previous table, with each journey’s budget deciding which rung it sits on. The budgets are examples of the shape, not recommendations for your product.

JourneyWho waitsBudget (p95)TierModel and effort
Customer support chatCustomer1.5 s to first tokenGlobal Standard with priority processingFlagship model, reasoning_effort: none
Agent-assist reply draftEmployee4 s to first tokenPTU with spillover to Global StandardMid-size model, low effort
Internal policy Q&AEmployee6 s to first tokenGlobal (or Data Zone) StandardSmall model
Contract clause reviewAnalyst, asyncMinutesGlobal (or Data Zone) Standard, queuedReasoning model, higher effort
Ticket classificationNobodyWithin 24 hGlobal BatchSmall model

Where residency rules out Global deployments, read “Data Zone” for “Global” throughout; the budgets don’t change. Global Batch’s 24-hour turnaround is a target, not a guarantee, so a job submitted at 6pm can legitimately finish at 6pm the next day. A hard morning deadline needs Global Standard or a submit time that leaves the full 24-hour window.

Two things stand out once you write a table like this. First, only two journeys in five need premium capacity, and only one of them pays a premium on every request; the agent-assist PTUs are sized for normal load and spill the rest to Standard. Second, model choice and reasoning effort are latency controls too. On GPT-5.1 and GPT-5.2, reasoning_effort accepts none (the default on GPT-5.1) per the reasoning models guide, and higher effort spends hidden output tokens before the first visible token appears. A journey with a 1.5-second budget can’t afford much reasoning, whatever tier it runs on. Buying priority capacity for a request that thinks for six seconds buys nothing the user can see.

Encoding the budget so it can’t drift

Budgets that live in a slide deck drift. I’d put them in configuration next to the code that calls the model, so the journey name decides the deployment and parameters, and the budget is logged with every call. This fragment uses the openai Python library (pip install "openai>=2.8") against the Azure OpenAI v1 endpoint.

import os
import time
from dataclasses import dataclass, field

from openai import OpenAI

# API key keeps the sample short. In production, use Microsoft Entra ID:
# api_key=get_bearer_token_provider(DefaultAzureCredential(),
#     "https://cognitiveservices.azure.com/.default") from azure-identity.
client = OpenAI(
    base_url="https://<your-resource-name>.openai.azure.com/openai/v1/",
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
)


@dataclass(frozen=True)
class JourneyBudget:
    deployment: str
    p95_first_token_s: float
    params: dict = field(default_factory=dict)
    extra_headers: dict = field(default_factory=dict)


BUDGETS = {
    # Customer-facing: flagship model with no reasoning, priority processing on Global Standard.
    "support_chat": JourneyBudget(
        deployment="<your-gpt-5.2-global-standard>",
        p95_first_token_s=1.5,
        params={"service_tier": "priority", "reasoning_effort": "none"},
    ),
    # Steady agent-assist traffic on PTUs, spilling to Standard instead of failing.
    "agent_assist": JourneyBudget(
        deployment="<your-gpt-5.1-provisioned>",
        p95_first_token_s=4.0,
        params={"reasoning_effort": "low"},
        extra_headers={"x-ms-spillover-deployment": "<your-gpt-5.1-standard>"},
    ),
    # Internal tool: shared capacity is fine.
    "policy_qa": JourneyBudget(
        deployment="<your-gpt-5-mini-global-standard>",
        p95_first_token_s=6.0,
        params={"reasoning_effort": "low"},
    ),
}


def ask(journey: str, messages: list[dict]) -> str:
    budget = BUDGETS[journey]
    start = time.perf_counter()
    first_token_s = None
    served_tier = None
    reasoning_tokens = None
    parts = []

    stream = client.chat.completions.create(
        model=budget.deployment,
        messages=messages,
        stream=True,
        stream_options={"include_usage": True},
        extra_headers=budget.extra_headers or None,
        **budget.params,
    )
    for chunk in stream:
        # The tier the service actually used: "priority" or "default" if downgraded.
        if chunk.service_tier:
            served_tier = chunk.service_tier
        # With include_usage, the final chunk carries usage and no choices.
        if chunk.usage and chunk.usage.completion_tokens_details:
            reasoning_tokens = chunk.usage.completion_tokens_details.reasoning_tokens
        if chunk.choices and chunk.choices[0].delta.content:
            if first_token_s is None:
                first_token_s = time.perf_counter() - start
            parts.append(chunk.choices[0].delta.content)

    first_token_s = first_token_s if first_token_s is not None else time.perf_counter() - start
    over_budget = first_token_s > budget.p95_first_token_s
    # Replace print with your telemetry; alert on the p95 per journey, not single calls.
    print(
        f"journey={journey} tier={served_tier} first_token_s={first_token_s:.2f} "
        f"reasoning_tokens={reasoning_tokens} over_budget={over_budget}"
    )
    return "".join(parts)


if __name__ == "__main__":
    print(ask("policy_qa", [{"role": "user", "content": "How many days of carer's leave do I get?"}]))

The spillover header is worth knowing about. Spillover sends a provisioned deployment’s overflow (most commonly 429s once PTUs are exhausted) to a Standard deployment of the same model in the same resource. That lets you size PTUs for normal load instead of peak, which is often the difference between provisioned capacity paying for itself and not. The trade-off is that spilled requests get Standard latency, so the journey’s budget has to tolerate that at peak.

Batch jobs don’t belong in this function at all. They go through the Batch API with a JSONL file and a Global Batch deployment, and their budget is a deadline, not a percentile.

Where this goes wrong

  • Budgets set by engineers alone. If product owners don’t agree the budget, the first complaint about a slow answer turns into “move everything to PTUs”. Get the number signed off.
  • Paying for capacity to hide a slow prompt. If time to first token is dominated by reasoning tokens or a huge uncached prompt, premium capacity barely helps. Measure first.
  • One deployment for every journey. Microsoft’s latency guidance recommends separating workloads, because long requests share capacity with short ones. Separate deployments per budget class also make the bill readable by journey.
  • Treating priority as guaranteed. Priority requests can be served at the standard tier during ramps, peaks or long-context calls, and you only find out from the service_tier in the response. I’d use priority processing for journeys where an occasional Standard-latency answer is acceptable, and look at PTUs when a customer SLA can’t absorb that.

When this is overkill

If you have one journey, modest traffic and no complaints, a single Global Standard deployment with streaming is the right answer, and a budget framework adds process without saving anything. The approach pays off once you have several journeys with different audiences, a bill someone is asking questions about, or a proposal to buy provisioned capacity. At that point the question to ask of every journey is simple: who is waiting, for how long, and what are we willing to pay so they wait less? Anything that can wait a day should be in Global Batch. Anything a customer watches deserves a measured budget and the cheapest tier that meets it. Everything else lives on Standard until the numbers say otherwise.

Michael John Peña

Michael John Peña

Data & AI Director and Microsoft MVP based in Sydney. Writing about Microsoft Fabric, Azure, data engineering, and AI.