1. Courses
  2. AI Engineering
  3. LLM Apps: Prompts, RAG and Evals
  4. Stop 1

35 minIntermediate

How LLM APIs work

Every feature in this course starts with one HTTP call to a language model. This lesson opens that call up: what goes in, what comes back, why the bill grows with every message, and how to keep the call fast and reliable when thousands of customers ask at once. By the end you will have a small, provider-neutral wrapper that the rest of the course builds on.

By the end you can

  • Read a chat-style request and response: system instructions, role-tagged messages, parameters, stop reason and token usage
  • Plan a context budget that shares room between instructions, history, product data and the answer, and trim history when it runs out
  • Choose sampling settings for extraction and for writing, and explain why temperature 0 is still not a promise of identical output
  • Stream answers with server-sent events, and retry rate limits and server errors with backoff and jitter
  • Work out the cost per question, per 1,000 questions and per month for a real feature

The everyday picture

Imagine you hire a brilliant phone interpreter. She speaks every language, but she has two odd rules. First, she forgets everything the moment you hang up. If you call back with "and what about the second question?", she has no idea what the first one was, so you have to read the whole conversation to her again before asking. Second, she bills by the word: every word you read to her, and every word she says back, and her own words cost several times more than yours.

She also has habits you learn to work with. She starts talking after a short pause, and you hear her answer as she speaks it, so you can start listening before she finishes. When her line is busy, the operator says "try again later", and if a hundred callers all redial at the same second, the line stays busy. A language model API behaves the same way: it remembers nothing between calls, it counts and bills tokens both ways, it can stream its answer, and it pushes back when too many calls arrive at once.

What one call looks like

Most model providers offer a chat-style API: you send one HTTPS request and get one response. The field names differ by provider, but the parts are the same everywhere. The request carries system instructions (the rules: who the assistant serves, what it may say, what format to use), a list of messages, each tagged with a role (user for the customer, assistant for the model's earlier replies), and parameters such as the model name, the most tokens the answer may use, and sampling settings. Some APIs take the system instructions as a separate field; others take them as the first message with a system role.

Here is the first raw call Nadia's team made for the product-question assistant in the Mangrove app. A customer is looking at a granola and asks about nuts:

JSON
{
  "model": "your-model-id",
  "system": "You answer Mangrove Market customers' questions about one product.\nUse ONLY the product facts below. If they do not answer the question, say so.\n\nProduct facts:\nOat Crunch Granola 500 g. Ingredients: oats, honey, almonds (8%), sunflower oil.\nAllergens: contains nuts (almonds). Made in a factory that also handles peanuts.",
  "messages": [
    { "role": "user", "content": "Does this granola have nuts? My son is allergic." }
  ],
  "max_tokens": 300,
  "temperature": 0.2
}

The response carries three things your code must read, not only the text:

JSON
{
  "content": [{ "type": "text", "text": "Yes. Oat Crunch Granola contains almonds, and it is made in a factory that also handles peanuts. It is not safe for a nut allergy." }],
  "stop_reason": "end_turn",
  "usage": { "input_tokens": 1540, "output_tokens": 38 }
}

The text is the answer. The says why the model stopped: it finished, it ran out of output tokens, or (in Tools and the Model Context Protocol) it wants to call a tool. The usage block counts input and output tokens, which is exactly what you are billed for. This example follows one provider's documented shape; another returns a list of choices, each with a finish_reason. Your wrapper maps whichever shape you get into one of your own, so the rest of the app never cares.

The most important fact about the API is that it is . The model keeps nothing between calls. When the customer follows up with "what about the honey one?", the app must send the system instructions, the first question, the first answer and the new question again. A conversation is a list your app keeps and resends, longer each turn. So the bill for one turn grows with the length of the conversation so far.

Sent on each turn Turn 1 1,540 in rules product Q1 Turn 2 1,780 in rules product Q1 A1 Q2 Turn 3 2,020 in rules product Q1 A1 Q2 A2 Q3 whole list, every call Model remembers nothing answer Turn 20: ~6,100 in
The model remembers nothing, so every turn resends the rules, the product facts and all earlier questions (Q) and answers (A). With 1,000 tokens of rules, 500 of product facts, 40-token questions and 200-token answers, input grows by 240 tokens a turn: 1,540, 1,780, 2,020, and about 6,100 by turn 20.

Quick check

A customer asks about the granola, then asks "is it also gluten-free?". The model answers about a different product. What is the most likely bug?

Tokens and the context budget

Models read and write , pieces of words chosen by the model's . In English, one token is roughly 4 characters, or about three quarters of a word, so 1,000 words is roughly 1,300 tokens. That rule of thumb breaks for other scripts. Text in Bengali, Hindi, Thai or Amharic often needs several times more tokens for the same meaning, because tokenizers were built mostly from English-heavy text. The ratio depends on the tokenizer, so never plan with the English rule. Take a sample of real customer questions in each language you serve, send them, and read the usage numbers (or use your provider's token-counting endpoint, if it has one).

The is the most the model can handle in one call. Current models range from tens of thousands of tokens to more than a million, depending on the model. Think of it as a budget shared by four users: your instructions, the conversation history, any data you attach (product facts now, retrieved help pages in Retrieval-augmented generation), and the answer itself. The window is the hard limit, but your own budget should usually be far smaller. Every input token is paid on every call, a longer input makes the first word slower to arrive, and models tend to use a key fact less reliably when it is buried in a very long input.

The answer has its own, separate limit: . You set it on every request, and models also have a ceiling of their own, usually much smaller than the window. If input plus the output you ask for does not fit in the window, depending on the provider the request is rejected or the answer is cut short. If the model reaches your max output tokens, it stops mid-sentence, and the stop reason says so (length in one API, max_tokens in another). A truncated sentence looks bad. Truncated JSON breaks the app. Check the stop reason on every call before you use the text.

One call's budget rules + product 1,500 tokens sent every call chat history up to 5,000 trim oldest first new question about 100 room for answer max output 600 you set this total 7,200 answer needs more cut off at 600 tokens stop reason says length
Mangrove's own budget for one call is 8,000 tokens, far below the model's window. Rules and product facts take 1,500, history gets up to 5,000 (oldest turns are trimmed first), the question about 100, and 600 are reserved for the answer. An answer that needs more is cut off, and the stop reason tells you.

When history outgrows its share, you have three choices. You can drop the oldest turns (cheap and simple, and what you will write in the Practice section), summarise old turns into a short note with an extra model call, or start a fresh conversation. For a product-question assistant, dropping old turns is usually fine: questions about the granola rarely need what was said twenty messages ago. Always keep the system instructions, and never cut a turn in half, because an answer without its question confuses the model.

Quick check

The assistant must return a small JSON object, and about 1 call in 50 fails to parse. The logs show those calls all have the stop reason for hitting the output limit. What should you do first?

Sampling: temperature and top-p

A model writes one token at a time. At each step it computes a probability for every token it knows, then samples one. After "Oat Crunch Granola contains", the token "almonds" might get 70%, "oats" 20%, and thousands of others share the rest. Two settings shape that choice.

rescales the probabilities before sampling. Low temperature sharpens them, so the top token wins almost every time. High temperature flattens them, so less likely tokens get picked more often, which reads as more varied and sometimes more creative, and also more often wrong. cuts the tail instead: with top-p 0.9, the model keeps only the most likely tokens that together make up 90% of the probability and samples among them. Ranges differ by provider (temperature is 0 to 1 in some APIs and 0 to 2 in others), providers usually advise changing one of the two settings and leaving the other alone, and some newer models fix these settings or reject them, so check the model's documentation.

Pick settings by task. For extraction and classification, such as pulling allergens out of a supplier's spec sheet or labelling a question as "delivery" or "product", use temperature 0 or close to it: you want the most likely reading every time. For writing, such as a friendly reply or three recipe ideas for leftover rice, a moderate temperature gives natural, varied wording. For answers that must be correct, low settings help but are never enough on their own; the checks in Prompts and structured output and the evals in Evals: knowing if it got better do that job.

Temperature 0 is not a promise of identical output. At temperature 0 the model picks the top token, but the probabilities it computes can differ slightly from run to run. Providers process many requests together in batches, and the batch size changes the order of floating-point arithmetic, which can flip a near-tie between two tokens. One flipped token changes everything written after it. Providers also update models and serving software behind the same name. So never write a test that expects the exact same sentence twice, and never cache an answer on the assumption that a re-run would match it.

Quick check

Tomás wants the model to read 25,000 supplier spec sheets and fill an allergens list for each product. Which setting fits?

Streaming, timeouts and retries

Two numbers describe how fast an answer feels. (TTFT) covers the network, any queue at the provider and the model reading your input; longer inputs make it longer. After that, output arrives at some tens of tokens per second, depending on the model and how busy the provider is. In Mangrove's first test the TTFT was about 0.8 seconds and output came at about 40 tokens per second, so a 200-token answer took about 0.8 + 200 ÷ 40 = 5.8 seconds in total.

Without streaming, the customer stares at a spinner for all 5.8 seconds. With streaming, the provider sends the answer in small pieces as it writes them, using (SSE): the response stays open and carries lines starting with data:, each event ended by a blank line. The first words appear after 0.8 seconds. The total time is the same; the wait feels far shorter, because reading starts almost at once.

One 200-token answer Without streaming blank screen, spinner whole answer at 5.8 s With streaming ~40 tokens a second first token 0.8 s done at 5.8 s 0 s 2 s 4 s 6 s
Streaming does not make the model faster. Both answers finish at about 5.8 seconds, but with streaming the customer starts reading at 0.8 seconds instead of waiting for the whole answer.

Streaming has costs. Structured output is not usable until it is complete: half a JSON object cannot be parsed, so the app either waits for the end or uses a parser that handles partial input. Checks on the whole answer, such as "does this allergen answer match the product facts?", cannot run before the customer has already read the first sentences. For answers where a wrong word is dangerous, buffer the reply, check it, then show it. And usage arrives at the end of the stream, if at all; some providers include it only when you ask, so make sure you still log it.

Calls also fail. Providers enforce , usually requests per minute and tokens per minute, and answer 429 Too Many Requests when you go over, often with a retry-after header saying how long to wait (Rate limiting explains the algorithms behind them). Servers also return 5xx errors when overloaded, and a call can hang. The rules:

  • Set a timeout on every call, so a hung connection does not hold a customer's request forever.
  • Retry only what can succeed next time: 429, 5xx, timeouts and dropped connections. A 400 (bad request, input too long) fails the same way every time.
  • Back off exponentially, with . Wait about 0.5, 1, 2, then 4 seconds, picking a random time up to that limit, and stop after a few attempts. Without jitter, every app server that hit the limit at 20:00:00 retries at exactly 20:00:01, and the limit is hit again. Respect retry-after when it is present.
  • Make retries safe. A model call changes nothing on the provider's side except your bill and your limits, so retrying it is for data, but not free. A call that timed out may have finished and been billed anyway. The danger is in your own code: if the handler saves the answer or counts the customer's daily questions before the call succeeds, a retry does it twice. Key that work by a request ID for the question. And once streamed text is on screen, do not retry silently; a second attempt writes different words.

Quick check

Which failures should callModel retry automatically?

What a call really costs

Providers price input and output tokens separately, per million tokens, and output usually costs several times more than input. Prices change often and differ by model, so this lesson uses illustrative prices only: say $3 per million input tokens and $15 per million output tokens for a large hosted model, and $0.25 and $1.25 for a small fast model. Check your provider's current price list before you plan with real numbers.

Output
cost of one call = input tokens x input price + output tokens x output price

Turn 1 (1,540 in, 200 out):   1,540 x $3/M + 200 x $15/M  = $0.00462 + $0.00300 = $0.00762
Three-question conversation:  input 1,540 + 1,780 + 2,020 = 5,340 tokens, output 600
                              5,340 x $3/M + 600 x $15/M  = $0.01602 + $0.00900 = $0.02502
Per question (average):       $0.02502 / 3                = $0.00834
Per 1,000 questions:                                        about $8.34

Three things stand out. Most of the input is tokens the customer never sees: rules and product facts, paid on every turn. Output is a small share of the tokens but a large share of the money, because each output token costs five times an input token here. And history makes later turns dearer, so a long chat costs much more per question than a short one.

Add what the simple formula leaves out: retries (a timed-out call may be billed), tool rounds in Tools and the Model Context Protocol (each round is a full new call), eval runs before every release, and festival peaks. Discounts for cached prompt prefixes and cheaper models for easy questions come in Cost, latency and safety. For the product manager's view of the same numbers, see How AI features work, for PMs; for the big picture of an AI feature with retrieval and evals, see Designing AI features.

Build it

A provider-neutral callModel wrapper with a timeout, retries with backoff and jitter, and a usage and cost log, plus a streaming version that reads server-sent events. Everything provider-specific sits in one marked block.

  1. Define your own types and prices

    The rest of the app only ever sees these shapes. Prices are integer cents per million tokens, so tokens × price is an exact integer in millionths of a cent; round only when you add up a bill.

    TypeScript
    // src/assistant/llm.ts: the only file that knows which provider you use
    export interface Message { role: 'user' | 'assistant'; content: string }
    export interface CallInput { system: string; messages: Message[]; maxOutputTokens: number; temperature?: number }
    export interface Usage { inputTokens: number; outputTokens: number }
    export interface CallResult {
      text: string;
      stopReason: 'end' | 'length' | 'other';
      usage: Usage;
      costMicrocents: number;   // millionths of a cent: 1,000,000 = 1 cent
      ms: number;
    }
    
    // ILLUSTRATIVE prices in cents per million tokens ($3 in, $15 out). Use your provider's price list.
    const PRICE = { inputCentsPerM: 300, outputCentsPerM: 1500 };
    
    export const costMicrocents = (u: Usage) =>
      u.inputTokens * PRICE.inputCentsPerM + u.outputTokens * PRICE.outputCentsPerM;
    
    const LENGTH_STOPS = new Set(['length', 'max_tokens']);   // the name differs by provider
    const toStop = (raw?: string): CallResult['stopReason'] =>
      !raw ? 'other' : LENGTH_STOPS.has(raw) ? 'length' : ['stop', 'end_turn'].includes(raw) ? 'end' : 'other';
  2. Isolate the provider-specific shape

    The endpoint, auth header, body fields and response paths differ by provider. These functions follow one provider's documented shape as an example; map yours from its API reference and change nothing else.

    TypeScript
    // ---------- SHAPE DIFFERS BY PROVIDER: adapt this block only ----------
    const ENDPOINT = process.env.LLM_URL!;   // your provider's chat endpoint
    const headers = () => ({
      'content-type': 'application/json',
      authorization: `Bearer ${process.env.LLM_KEY}`,   // some providers use a different header
    });
    const toProviderBody = (i: CallInput, stream: boolean) => ({
      model: process.env.LLM_MODEL,
      system: i.system,                     // some APIs want this as the first message instead
      messages: i.messages,
      max_tokens: i.maxOutputTokens,        // field name differs by provider
      temperature: i.temperature ?? 0.2,
      stream,
    });
    const fromProviderResponse = (j: any) => ({
      text: (j.content ?? []).map((p: any) => p.text ?? '').join(''),
      rawStop: j.stop_reason as string | undefined,
      usage: { inputTokens: j.usage?.input_tokens ?? 0, outputTokens: j.usage?.output_tokens ?? 0 },
    });
    const fromProviderEvent = (e: any) => ({   // one streamed event
      text: e.delta?.text as string | undefined,
      rawStop: e.delta?.stop_reason as string | undefined,
      inputTokens: e.message?.usage?.input_tokens as number | undefined,
      outputTokens: e.usage?.output_tokens as number | undefined,
    });
    // ---------- end of provider-specific block ----------
  3. Call with a timeout, retries, backoff with jitter, and a log line

    TypeScript
    const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);   // add your provider's "overloaded" code
    const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
    
    export async function callModel(input: CallInput, timeoutMs = 20_000, maxAttempts = 4): Promise<CallResult> {
      for (let attempt = 1; ; attempt++) {
        const started = Date.now();
        const res = await fetch(ENDPOINT, {
          method: 'POST', headers: headers(), body: JSON.stringify(toProviderBody(input, false)),
          signal: AbortSignal.timeout(timeoutMs),            // never wait forever
        }).catch((e: Error) => e);                          // timeout or dropped connection
        const status = res instanceof Error ? 0 : res.status;
    
        if (status === 200) {
          const r = fromProviderResponse(await (res as Response).json());
          const result: CallResult = { text: r.text, stopReason: toStop(r.rawStop), usage: r.usage,
            costMicrocents: costMicrocents(r.usage), ms: Date.now() - started };
          console.log(JSON.stringify({ event: 'llm_call', attempt, ...r.usage,
            costMicrocents: result.costMicrocents, ms: result.ms, stop: result.stopReason }));
          return result;                                     // the caller must check stopReason
        }
        if ((status !== 0 && !RETRYABLE.has(status)) || attempt >= maxAttempts) {
          throw new Error(`model call failed: ${status || (res as Error).name} after ${attempt} attempt(s)`);
        }
        const retryAfterS = res instanceof Error ? 0 : Number(res.headers.get('retry-after'));
        const cap = Math.min(8_000, 500 * 2 ** (attempt - 1));   // 0.5 s, 1 s, 2 s, 4 s ...
        const waitMs = retryAfterS > 0 ? retryAfterS * 1000 : Math.random() * cap;   // full jitter
        console.log(JSON.stringify({ event: 'llm_retry', attempt, status, waitMs: Math.round(waitMs) }));
        await sleep(waitMs);
      }
    }
    Output
    {"event":"llm_retry","attempt":1,"status":429,"waitMs":2000}
    {"event":"llm_call","attempt":2,"inputTokens":1540,"outputTokens":38,"costMicrocents":519000,"ms":1710,"stop":"end"}

    519,000 millionths of a cent is about half a cent. Ship these log lines to your log store (Observability), and you can chart cost per day, tokens per question and retries per hour from them.

  4. Stream the answer from server-sent events

    Raw SSE from a provider looks like this: data: lines with one JSON event each, separated by blank lines. Event names and fields differ by provider, and one provider ends the stream with data: [DONE].

    Output
    data: {"type":"message_start","message":{"usage":{"input_tokens":1540,"output_tokens":1}}}
    
    data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Yes. Oat Crunch"}}
    
    data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":38}}
    TypeScript
    export async function streamModel(input: CallInput, onText: (piece: string) => void, timeoutMs = 60_000) {
      const started = Date.now();
      const res = await fetch(ENDPOINT, { method: 'POST', headers: headers(),
        body: JSON.stringify(toProviderBody(input, true)), signal: AbortSignal.timeout(timeoutMs) });
      if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`);  // safe to retry: nothing shown yet
      const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
      const usage: Usage = { inputTokens: 0, outputTokens: 0 };
      let buf = '', text = '', rawStop: string | undefined, ttftMs = -1;
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        buf += value.replace(/\r/g, '');
        let cut: number;
        while ((cut = buf.indexOf('\n\n')) >= 0) {                 // a blank line ends one event
          const block = buf.slice(0, cut);
          buf = buf.slice(cut + 2);
          const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
          if (!data || data === '[DONE]') continue;
          const e = fromProviderEvent(JSON.parse(data));
          if (e.text) {
            if (ttftMs < 0) ttftMs = Date.now() - started;         // time to first token
            text += e.text;
            onText(e.text);                                        // push to the app as it arrives
          }
          if (e.rawStop) rawStop = e.rawStop;
          if (e.inputTokens) usage.inputTokens = e.inputTokens;
          if (e.outputTokens) usage.outputTokens = e.outputTokens;
        }
      }
      const ms = Date.now() - started;
      console.log(JSON.stringify({ event: 'llm_stream', ...usage, costMicrocents: costMicrocents(usage), ttftMs, ms,
        tokensPerSecond: ttftMs < ms ? Math.round(usage.outputTokens / ((ms - ttftMs) / 1000)) : null, stop: toStop(rawStop) }));
      return { text, stopReason: toStop(rawStop), usage };
    }

    Your API passes each piece on to the Mangrove app over its own SSE response. If the stream breaks after text was shown, send the app an error event and let the customer ask again; a silent retry would write different words under the ones already on screen.

  5. Use it, and check the stop reason

    TypeScript
    const reply = await callModel({ system: rulesAndProductFacts, messages: history, maxOutputTokens: 600, temperature: 0.2 });
    if (reply.stopReason === 'length') {
      // Never show or parse a cut-off answer. Log it, and fall back to a safe message.
      return { text: 'Sorry, I could not answer that fully. Please check the product label or ask our support team.' };
    }
    return { text: reply.text };

Bring it to your team

Ask these before the first AI call ships, and again before a festival season.

  • Where is our one wrapper around the model API, and does every call go through it?
  • What is our token budget per call, and what happens to history when it runs out?
  • Do we check the stop reason on every call before showing or parsing the answer?
  • Which errors do we retry, with what backoff, and how many attempts before we give up?
  • What is our cost per 1,000 questions, from measured usage, and what volume assumption sits behind the monthly number?
  • What is our tokens-per-minute limit, and will it hold at 4× traffic on a festival day?

Red flags

  • Token counts estimated with the English rule for customers who write in other scripts.
  • Retries with a fixed delay and no jitter, or retries on 400 errors.
  • No usage logging, so nobody knows the cost until the invoice arrives.
  • Tests that expect the exact same answer twice "because temperature is 0".
  • Provider SDK calls scattered across the codebase instead of one wrapper.

Practice

Trim the history to fit the budget

Write trimHistory(messages, budgetTokens, estimateTokens). messages[0] is the system message, and the last message is the customer's new question. estimateTokens(text) returns a token count for a string. Return a new array that always keeps the system message and the new question, then adds earlier messages from newest to oldest while the total still fits in budgetTokens, stopping at the first one that does not fit. Keep the original order. The kept history must not start with an assistant message (an answer cut off from its question), so drop any assistant messages at the start of what you kept. Do not change the input array.

JavaScript exerciseRuns in your browser with tests
// messages: [{ role: 'system' | 'user' | 'assistant', content: '...' }]
// messages[0] is the system message; the last one is the new question.
function trimHistory(messages, budgetTokens, estimateTokens) {
  return messages;
}
Ctrl + Enter runs
Show a solution
JavaScript
function trimHistory(messages, budgetTokens, estimateTokens) {
  const system = messages[0];
  const question = messages[messages.length - 1];
  const cost = (m) => estimateTokens(m.content);
  let used = cost(system) + cost(question);        // these two are always sent
  const kept = [question];
  for (let i = messages.length - 2; i >= 1; i--) {  // newest earlier message first
    const c = cost(messages[i]);
    if (used + c > budgetTokens) break;             // stop at the first that does not fit
    used += c;
    kept.unshift(messages[i]);
  }
  while (kept.length > 1 && kept[0].role === 'assistant') kept.shift();  // no orphan answers
  return [system, ...kept];
}
Pass the exercise to finish this task.

Budget the festival month

Nadia needs the assistant's cost for a festival month. Use the illustrative prices ($3 per million input tokens, $15 per million output). A conversation averages 3 questions, 5,340 input tokens and 600 output tokens in total. On 26 normal days there are about 6,700 conversations a day; on 4 festival days traffic runs at 4×. Work out the cost per conversation, the month's total, and name two things that could make the real bill higher than your number.

  • Cost per conversation is about 2.5 cents (5,340 × $3/M + 600 × $15/M)
  • The month has 26 × 6,700 + 4 × 26,800 = 281,400 conversations, costing about $7,000
  • You name at least two real reasons the bill could be higher, such as longer chats, retries that were billed, more customers than assumed, or other scripts using more tokens
  • You state the volume as an assumption, not a fact
Show a model answer
Output
Per conversation:  5,340 x $3/M + 600 x $15/M = $0.01602 + $0.00900 = $0.02502
Normal days:       26 x 6,700           = 174,200 conversations
Festival days:     4 x 6,700 x 4        = 107,200 conversations
Month:             281,400 x $0.02502   = about $7,040

About $7,000, against about $5,000 in a normal month. The volume is an assumption, since the assistant has no history yet. The bill could be higher if festival chats run longer (each extra turn resends the whole history), if timeouts during the rush cause retries that were billed anyway, if more customers than 5% try it when it is promoted, or if more questions arrive in scripts that need more tokens. A daily spending alert catches all four early.

Check yourself

With the illustrative prices ($3 per million input, $15 per million output), Nadia proposes cutting the system rules from 1,000 to 500 tokens, and Leila proposes cutting the average answer from 200 to 100 tokens. Which saves more on a first question?

In a test, the assistant's time to first token is 0.8 seconds and it writes about 40 tokens a second. Answers average 200 tokens. Leila asks whether streaming will make answers arrive sooner. What is the accurate reply?

On a festival evening, Mangrove's 12 API servers all hit the provider's rate limit at the same moment. Each retries after exactly 1, 2 and 4 seconds, and errors keep spiking in waves. What fixes the waves?

Flashcards

Open my review

Key takeaways

  • A call is system instructions, role-tagged messages and parameters in; text, a stop reason and token usage out. Read all three.
  • The API is stateless: the app resends the conversation every turn, so cost per question grows with history.
  • Tokens are the unit of price, limits and speed. The window is a budget shared with the answer; measure tokens with real text in every language.
  • Low temperature for extraction, moderate for writing, and no promise of identical output even at 0.
  • Stream for perceived speed, time out every call, retry 429 and 5xx with backoff and jitter, and log usage and cost on every call.

Sources

Episode transcriptThe interpreter who forgets every call · about 13 minutes · tap any line to play from there
  1. NadiaImran, a customer is in the Mangrove app looking at a granola. She types, does this have nuts, my son is allergic. What happens behind the answer?
  2. ImranThe app sent her question to the model, and the model sent back an answer. One request, one reply.
  3. NadiaThat's the shape, and almost everything about cost, speed and reliability lives inside it. Start with a picture. Imagine you hire a brilliant phone interpreter. She speaks every language, but she forgets everything the moment you hang up.
  4. ImranSo if I call back and say, and what about my second question, she has no idea what the first one was.
  5. NadiaExactly. You have to read the whole conversation to her again before you ask. And she bills by the word. Every word you read to her, and every word she says back. Her own words cost several times more than yours.
  6. NadiaTwo more habits. She starts talking after a short pause, and you hear her answer as she speaks, so you can listen before she's done. And when her line is busy, the operator says try again later.
  7. ImranAnd if a hundred callers all redial in the same second, the line stays busy.
  8. NadiaThat's a language model API in one story. No memory between calls, billed by tokens both ways, it can stream, and it pushes back when too many calls arrive.
  9. ImranOkay, so what's actually in the request?
  10. NadiaThree parts. System instructions, which are the rules. A list of messages, each tagged with a role, user for the customer and assistant for the model's earlier replies. And parameters, like the model name and the most tokens the answer may use.
  11. ImranAnd the response is just the text?
  12. NadiaThat's the trap. The response has three things you must read. The text. The stop reason, which says why the model stopped. And the usage block, which counts input and output tokens. That last one is literally your bill.
  13. ImranWhy does the stop reason matter if I have the text?
  14. NadiaBecause the model may have stopped because it ran out of room. We'll come back to that. First, the biggest idea. The API is stateless. The model keeps nothing between calls.
  15. NadiaIf you can see the screen, the first diagram shows three rows, one per turn. If not, picture this. Turn one sends the rules, the product facts and the first question. About one thousand five hundred and forty tokens.
  16. ImranAnd turn two sends all of that again, plus the first answer, plus the new question.
  17. NadiaYes. About one thousand seven hundred and eighty, then about two thousand and twenty. By turn twenty you're sending about six thousand one hundred tokens for one short question.
  18. ImranSo a long chat costs a lot more per question than a short one. I'd have guessed every question costs the same.
  19. NadiaMost people do. And if the app forgets to resend the earlier turns, a follow-up like is it also gluten free makes no sense to the model.
  20. ImranNow tokens. I know a token is part of a word.
  21. NadiaIn English, roughly four characters, or three quarters of a word. Other scripts, like Bengali, Hindi or Thai, often need several times more tokens for the same meaning. In our test, Bengali and Thai questions used about three times as many. So measure with real questions in each language.
  22. NadiaNext, the context window. That's the most the model can handle in one call. The second diagram shows it as a stack. Think of it as a budget shared by four users: the instructions, the history, any data you attach, and the answer itself.
  23. ImranAnd current models have huge windows, right? So why worry?
  24. NadiaBecause every input token is paid on every call, and long inputs make the first word slower. So our own budget is eight thousand tokens a call. Rules and product facts take fifteen hundred, history up to five thousand, the question about a hundred, and six hundred are kept for the answer.
  25. ImranWhat happens if the answer needs more than six hundred?
  26. NadiaIt stops mid sentence. And the stop reason says it hit the length limit. A cut-off sentence looks bad. Cut-off JSON breaks the app. So you check the stop reason on every call before you show or parse anything.
  27. ImranAnd when the history outgrows its five thousand?
  28. NadiaDrop the oldest turns first. Always keep the instructions, and never cut a turn in half, because an answer without its question confuses the model. That's the practice exercise, by the way.
  29. ImranLet me guess the next one. Temperature. Set it to zero and you always get the same answer.
  30. NadiaThat's the reasonable guess, and it's wrong. The model picks one token at a time from a list of probabilities. Temperature sharpens or flattens that list. Low temperature, the top token wins almost every time. Top p is the other setting. It cuts off the unlikely tail.
  31. ImranSo at zero it always takes the top token. Why would the answer change?
  32. NadiaBecause the probabilities can shift a tiny bit between runs. Providers process requests in batches, and the batch size changes the order of the arithmetic. That can flip a near tie, and one flipped token changes everything after it.
  33. ImranSo which settings do we use?
  34. NadiaFor extraction, like pulling allergens out of a supplier spec sheet, temperature zero or close to it, and still check a sample. For writing, a moderate temperature. And never write a test that expects the same sentence twice.
  35. ImranNow speed. The customer is waiting.
  36. NadiaTwo numbers. Time to first token, and tokens per second. In our first test, the first token came after about point eight seconds, then about forty tokens a second. The third diagram shows a two hundred token answer both ways.
  37. ImranSo two hundred divided by forty is five seconds, plus point eight. About five point eight seconds total.
  38. NadiaRight. Without streaming, the customer stares at a spinner for all of it. With streaming, the words start appearing after point eight seconds. The total time is the same. It just feels much shorter. On the wire that is server sent events: the response stays open and carries small pieces, each ended by a blank line.
  39. ImranAny catch?
  40. NadiaTwo. Half a JSON object can't be parsed, so structured output has to wait for the end. And checks on the whole answer can't run before the customer has read the first sentences. So for allergen answers, we buffer the reply, check it, then show it.
  41. ImranAnd the busy line from the story?
  42. NadiaThat's rate limits. Providers cap requests and tokens per minute, and answer with a four twenty-nine, too many requests, often with a header saying how long to wait. Servers also throw five hundred errors when they're overloaded, and sometimes a call just hangs.
  43. ImranSo we retry everything until it works.
  44. NadiaNo. Set a timeout on every call, and retry only what can succeed next time: four twenty-nine, five hundreds, timeouts, dropped connections. A four hundred, a bad request, fails the same way every time. And wait longer each time, with a random amount mixed in. That's called jitter.
  45. ImranWhy the randomness?
  46. NadiaPicture a festival evening. All our API servers hit the limit at the same second. If each one waits exactly one second, they all come back together and hit the limit again. Errors arrive in waves. Jitter spreads them out.
  47. ImranAnd retrying a model call is harmless, since it doesn't change any data.
  48. NadiaHarmless for data, not free. A call that timed out may have been billed anyway. And the danger is in your own code. If you count the customer's daily questions before the call succeeds, a retry counts twice. And once streamed text is on screen, don't retry silently.
  49. ImranOkay, the money. What does one question really cost?
  50. NadiaInput and output are priced separately, per million tokens, and output usually costs several times more. The lesson uses illustrative prices: three dollars per million input tokens and fifteen per million output. Check your provider's price list for real ones.
  51. ImranSo a first question, fifteen hundred and forty tokens in, two hundred out?
  52. NadiaAbout point seven six of a cent. A three question conversation, with the history resent, comes to about two and a half cents. That's about eight dollars and thirty cents per thousand questions.
  53. NadiaThen Ken asked for a monthly number. We have about two million registered customers and no usage data yet, so we wrote down an assumption. About five percent use the assistant in a month, two conversations each, three questions each.
  54. ImranSo a hundred thousand people, two hundred thousand conversations, six hundred thousand questions.
  55. NadiaTimes two and a half cents, roughly five thousand dollars a month. About what one engineer costs us. The illustrative small model would be about four hundred and twenty. Whether its answers are good enough is a job for evals, later in the course.
  56. ImranAnd you gave Ken one number?
  57. NadiaA range, four to eight thousand a month, with every assumption written next to it. And we asked for a higher tokens per minute limit before festival season.
  58. ImranSo what do I actually build?
  59. NadiaOne wrapper, called call model, that every part of the app goes through. All the provider specific parts sit in one marked block: the endpoint, the auth header, the field names. The shape differs by provider, so that's the only part you adapt.
  60. NadiaInside it, a timeout, retries on rate limits and server errors with backoff and jitter, and one log line per call with tokens, cost, time and stop reason. Then a streaming version that reads the events, records the time to first token, and still logs usage at the end.
  61. ImranAnd the exercise is the history trimming you mentioned.
  62. NadiaYes. Keep the system message and the new question, then add earlier messages from newest to oldest while they fit. And make sure the kept history never starts with an answer whose question was dropped.
  63. ImranFor my next review, what do I ask?
  64. NadiaDo all calls go through one wrapper? Do we check the stop reason every time? Which errors do we retry, and with what backoff? And what's our cost per thousand questions from measured usage, not from guesses?
  65. NadiaNow a question for the listener. Leila wants to cut answers from two hundred tokens to one hundred. I want to cut the rules from one thousand tokens to five hundred. With the illustrative prices, who saves more? Take a second.
  66. ImranI'd say you, since you're cutting five hundred tokens and she's cutting a hundred.
  67. NadiaThat's the trap. Output tokens cost five times more here. Five hundred input tokens and one hundred output tokens cost the same, about point one five of a cent. It's a tie.
  68. ImranLet me sum up. One, the model remembers nothing, so we resend the whole conversation and each turn costs more. Two, read the stop reason and the usage, not just the text. Three, stream for speed, and retry only rate limits and server errors, with jitter.
  69. NadiaGood. One nuance on the first. Count tokens with real text in every language you serve, because the English rule of four characters a token can be off by several times.
  70. ImranAnd next time?
  71. NadiaPrompts and structured output. How to get answers the app can trust, as JSON it can check against a schema.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

API client with retries

A fetch wrapper with a timeout, retries with growing waits, and errors that say what failed.

Open in the Code Lab
Next stopPrompts and structured output
Was this lesson helpful?
Questions and discussion

Loading the discussion…