Cost and latency: budget, cache, a smaller model
In short: The cost of a language model is tokens × price, separately for input and output; a token budget, an answer cache and a smaller model cut the bill, and the cache and the smaller model also cut the response time.
Problem
The bill at the end of the month
The support assistant has been working for a month. It answers correctly, and the set from the lesson on evaluation passes. Then two complaints arrive: the bill for the model is higher than expected, and customers wait about a second for an answer.
Here is one call as the assistant makes it. The token prices are examples, in euro per million tokens:
const question = "How much does delivery cost?";const r = CLIENT.create({ model: LARGE, max_tokens: 1024, system: PROMPT + " " + ALL_DOCS, messages: [{ role: "user", content: question }] });const m = MODELS[LARGE];const euro = cost(m, r.usage);const input = r.usage.input_tokens * m.inPrice / 1_000_000;console.log(`input: ${r.usage.input_tokens} tokens, output: ${r.usage.output_tokens} tokens`);console.log(`cost: €${euro.toFixed(5)} (the input is ${(100 * input / euro).toFixed(0)}%)`);console.log(`example time: ${latency(m, r.usage)} ms`);input: 358 tokens, output: 28 tokenscost: €0.00188 (the input is 76%)example time: 995 msThe question “How much does delivery cost?” is 10 tokens, and the input is 358. Part of the bill is the answer, and the larger part is the input: the system prompt and all the shop documents travel with every question. And in the log half the questions are the same: how much is delivery, when is support open.
The idea
Where the money and the time go
A model is paid in tokens (the lesson on tokens), separately for the input and for the output:
- the input is everything you send: the system prompt, the documents, the history of the conversation, the question. The API has no memory, so all of it is sent every time (the lesson on context);
- the output is the answer. An output token usually costs several times more than an input token, here 4 times.
Time is similar, with a different balance. The model reads the input fast, and writes the answer token by token. So a long answer is slow, and a long input is mostly expensive.
That gives the three fixes the lesson measures with a program:
- a token budget: less input (only what is needed) and a ceiling on the output;
- a cache: the same question does not reach the model;
- a smaller model for the easy questions: a cheaper and faster token.
A token budget is an upper limit on the tokens of one call, planned in advance, the way you plan the memory or the response time of one request.
See it
The assistant in code
The fake client is the same as in the lesson on evaluation: the shape of the real API, recorded answers and rough tokens (every word is 2 tokens). The answers are recorded by a word in the question:
// a fake client: the shape of the real API, the answers are recorded// roughly: every word is 2 tokensfunction tokens(text) { return 2 * text.trim().split(/\s+/).length;}
// max_tokens cuts the answerfunction reply(text, maxTokens, input) { const words = text.split(" "); if (2 * words.length <= maxTokens) return { text, stop_reason: "end_turn", usage: { input_tokens: input, output_tokens: 2 * words.length } }; const kept = Math.floor(maxTokens / 2); const cut = words.slice(0, kept).join(" "); return { text: cut, stop_reason: "max_tokens", usage: { input_tokens: input, output_tokens: 2 * kept } };}
class FakeLlm { // a recorded answer: model ("" = any), a word in the question, text the prompt needs constructor(recorded) { this.recorded = recorded; }
create({ model, max_tokens, system, messages }) { const question = messages[messages.length - 1].content; let input = tokens(system); for (const m of messages) input += tokens(m.content); for (const r of this.recorded) { const forModel = r.model === "" || r.model === model; if (forModel && question.includes(r.keyword) && system.includes(r.needs)) return reply(r.text, max_tokens, input); } return reply("(no recorded answer)", max_tokens, input); }}const RECORDED = [ { model: "", keyword: "cost", needs: "", text: "Delivery to an address is €4.90, to an office €3.50, and free over €50." }, { model: "", keyword: "cash", needs: "", text: "Yes, you can pay cash on delivery. The fee is €1.50." }, { model: "", keyword: "return", needs: "", text: "You can return the goods within 14 days of delivery if they are unused and have the label." }, { model: "", keyword: "open", needs: "", text: "Support is open Monday to Friday, 9 am to 6 pm." }, { model: "", keyword: "1042", needs: "", text: "Order 1042 has shipped and arrives on Thursday." }, { model: "", keyword: "1077", needs: "", text: "Order 1077 is being packed and leaves tomorrow." }, { model: "", keyword: "damaged", needs: "", text: "I am sorry! Take a photo of the parcel and the label and write to us with the order number. We will send a new one or refund you within 5 working days." }, { model: "", keyword: "days", needs: "", text: "Parcels arrive in 2 to 4 working days." }, { model: "", keyword: "wholesale", needs: "", text: "I do not know, I will connect you with a colleague." }, { model: "", keyword: "Mastercard", needs: "", text: "Yes, we take both Visa and Mastercard." },];The shop documents are five sections. Each has words that identify it:
const PROMPT = "You are the support assistant of an online shop. Answer briefly and only from the documents. If the answer is not there, say: “I do not know, I will connect you with a colleague.”";// a section of the documents and the words that identify itconst DOCS = [ { triggers: ["delivery", "parcel"], text: "Delivery: parcels arrive in 2 to 4 working days. Delivery to an address costs €4.90, to a courier office €3.50, and it is free over €50. A parcel is tracked by its number." }, { triggers: ["return", "damaged"], text: "Returns: goods can be returned within 14 days of delivery if they are unused and have the label. A damaged parcel is photographed and reported to support. The money goes back to the card within 5 working days." }, { triggers: ["pay", "card", "cash"], text: "Payment: we take Visa and Mastercard. Cash on delivery has a fee of €1.50. Card payment is protected, and the amount is charged when the order is confirmed." }, { triggers: ["order"], text: "Orders: the status of an order is shown in the profile. Support checks an order only by its number and never asks for card details." }, { triggers: ["open", "hours"], text: "Support: we work Monday to Friday, 9 am to 6 pm. Write to us at help@shop.example." },];const ALL_DOCS = DOCS.map((doc) => doc.text).join(" ");The models, the prices and the times. All the numbers here are examples, and the time comes from a formula, it is not measured:
// example prices in € per 1,000,000 tokens and example time (not measured)const LARGE = "model-large", SMALL = "model-small";const MODELS = { [LARGE]: { inPrice: 4.00, outPrice: 16.00, baseMs: 400, inputPerMs: 10, msPerToken: 20 }, [SMALL]: { inPrice: 0.40, outPrice: 1.60, baseMs: 150, inputPerMs: 40, msPerToken: 5 },};const CACHE_MS = 5;// the cost of one call in €function cost(m, u) { return (u.input_tokens * m.inPrice + u.output_tokens * m.outPrice) / 1_000_000;}
// example time in ms: start + reading the input + generatingfunction latency(m, u) { return m.baseMs + Math.floor(u.input_tokens / m.inputPerMs) + u.output_tokens * m.msPerToken;}The answer for order 1042 is recorded. In the real assistant the status comes from a tool (the lesson on tools).
1_000_000 is the same as 1000000. In JavaScript / always gives a fraction, so Math.floor drops the remainder, like integer division in the other languages.Try it
Fill in
The cost is the input tokens times the input price plus the output tokens times…?
return (u.input_tokens * m.inPrice + u.output_tokens * ) / 1_000_000;The output has its own price, here €16 per million against €4 for the input. If you price the output at the input rate, the bill for the answers comes out four times too low, and they are the more expensive part per token.
See it
One day
A day in support is a sample of 20 questions. Some repeat, sometimes in lower case or with an extra space. Three are about a specific order:
const questions = [ "How much does delivery cost?", "When is support open?", "Where is order 1042?", "how much does delivery cost?", "Can I pay cash on delivery?", "Until when can I return the goods?", "How much does delivery cost?", "My parcel came damaged, what do I do?", "When is support open?", "Where is order 1077?", "Do you take Mastercard?", "How much does delivery cost? ", "How many days does a parcel to Burgas take?", "Can I pay cash on delivery?", "Do you sell wholesale?", "Until when can I return the goods?", "Where is order 1042?", "When is support open?", "How much does delivery to an office cost?", "How do I return the goods?",];day runs the questions and adds up the cost and the time. The three flags switch on the three fixes, and for now they are off:
function day(questions, budget, cache, route) { const answers = new Map(); let euro = 0; let ms = 0, hits = 0, onSmall = 0; for (const question of questions) { const k = key(question); // a hit: no model if (cache && answers.has(k)) { hits++; ms += CACHE_MS; continue; } const model = route ? pickModel(question) : LARGE; const docs = budget ? relevant(question) : ALL_DOCS; const maxTokens = budget ? 200 : 1024; const msg = { role: "user", content: question }; const r = CLIENT.create({ model, max_tokens: maxTokens, system: PROMPT + " " + docs, messages: [msg], }); const m = MODELS[model]; euro += cost(m, r.usage); ms += latency(m, r.usage); if (model === SMALL) onSmall++; // an order belongs to one customer: no cache if (cache && !k.includes("order")) answers.set(k, r.text); } return { euro, ms, hits, onSmall };}function row(name, t, n) { console.log(`${name}: €${(t.euro / n * 1000).toFixed(2)} per 1000 questions, ${Math.floor(t.ms / n)} ms on average`);}
function month(t, n) { console.log(`a month at 3000 questions a day: €${(t.euro / n * 3000 * 30).toFixed(2)}`);}const all = day(questions, false, false, false);row("large model, all documents", all, questions.length);month(all, questions.length);large model, all documents: €1.85 per 1000 questions, 959 ms on averagea month at 3000 questions a day: €166.93The cost per 1000 questions is under €2. At 3000 questions a day the month is about €167, and a question waits almost a second on average.
The idea
A token budget
The first fix is the input. The question about delivery needs only the delivery section. relevant takes the sections whose words are in the question, and if there is none, it sends them all (the question about wholesale has to see everything to say “I do not know”):
// budget: only the sections whose words are in the questionfunction relevant(question) { const low = question.toLowerCase(); const parts = DOCS.filter((doc) => doc.triggers.some((word) => low.includes(word))).map((doc) => doc.text); return parts.length > 0 ? parts.join(" ") : ALL_DOCS;}The second ceiling is max_tokens: at most 200 tokens for an answer. It does not make the answers shorter. It says how much one call can cost at most.
const all = day(questions, false, false, false);row("large model, all documents", all, questions.length);const budget = day(questions, true, false, false);row("+ token budget", budget, questions.length);large model, all documents: €1.85 per 1000 questions, 959 ms on average+ token budget: €1.05 per 1000 questions, 939 ms on averageThe cost drops by almost half. The time barely moves: the input is read fast, and the answer is the same.
Here the sections are picked by words. How to pick them by meaning, with vectors, is in the module on semantic search (RAG).
doc.triggers.some((word) => low.includes(word)) is true if at least one word of the section is in the question.Try it
Predict the output
max_tokens is 10, and the question is about a damaged parcel. What will it print?
const r = CLIENT.create({ model: LARGE, max_tokens: 10, system: PROMPT, messages: [{ role: "user", content: "My parcel came damaged, what do I do?" }] });console.log(r.text);console.log(`${r.stop_reason}, ${r.usage.output_tokens}`);The ceiling stops the answer at 10 tokens, here 5 words, in the middle of a sentence. stop_reason is max_tokens, not end_turn. The ceiling protects the bill, but a ceiling that is too low gives half an answer. The program has to check stop_reason before it shows the answer to the customer.
The idea
An answer cache
The second fix is repetition. If the same question was asked a moment ago, the answer is the same: there is no need to ask the model again. This is cache-aside from the lesson "Caching": you look in the cache, and on a miss you ask the model and keep a copy.
The key is the question in lower case with one space each, so “how much does delivery cost? ” is the same as “How much does delivery cost?”:
// the cache key: lower case, one space eachfunction key(question) { return question.trim().toLowerCase().split(/\s+/).join(" ");}In day these are the lines with answers: a hit returns the stored answer in 5 ms and for free. A question about an order is not cached. The answer belongs to one customer, and the status changes.
const all = day(questions, false, false, false);row("large model, all documents", all, questions.length);const budget = day(questions, true, false, false);row("+ token budget", budget, questions.length);const cached = day(questions, true, true, false);row("+ cache", cached, questions.length);console.log(`from the cache: ${cached.hits} of ${questions.length}`);large model, all documents: €1.85 per 1000 questions, 959 ms on average+ token budget: €1.05 per 1000 questions, 939 ms on average+ cache: €0.70 per 1000 questions, 610 ms on averagefrom the cache: 7 of 207 of 20 questions come from the cache. Now the time drops too, because a hit does not wait for the model.
Try it
Find the bug
A colleague simplifies the cache. The cost tests pass, and a day later a customer complains that the assistant told them the status of someone else's order. Which line has the bug?
Pick the line with the bug.
The cache keeps every answer, the answers about orders too. In the real assistant the question “Where is my order?” is the same for all customers, and the answer belongs to the first one who asked. The next customer gets someone else’s status, and the first one gets an old one a day later. An answer about an order is not cached: that is the check the colleague removed.
The idea
A smaller model for the easy ones
The third fix is the model. The small model is 10 times cheaper per token and writes faster. In the lesson on evaluation the set showed where it does fine: the delivery price, payment, returns. And where it does not: the delivery time and the question outside the documents. So it gets only the approved topics:
// the easy topics: the test set showed the small model handles themconst EASY = ["cost", "cash", "return", "Mastercard"];
function pickModel(question) { return EASY.some((word) => question.includes(word)) ? SMALL : LARGE;}const all = day(questions, false, false, false);row("large model, all documents", all, questions.length);const budget = day(questions, true, false, false);row("+ token budget", budget, questions.length);const cached = day(questions, true, true, false);row("+ cache", cached, questions.length);const routed = day(questions, true, true, true);row("+ small model for the easy ones", routed, questions.length);console.log(`on the small model: ${routed.onSmall} of ${questions.length}`);month(all, questions.length);month(routed, questions.length);large model, all documents: €1.85 per 1000 questions, 959 ms on average+ token budget: €1.05 per 1000 questions, 939 ms on average+ cache: €0.70 per 1000 questions, 610 ms on average+ small model for the easy ones: €0.42 per 1000 questions, 408 ms on averageon the small model: 6 of 20a month at 3000 questions a day: €166.93a month at 3000 questions a day: €37.36The month drops from about €167 to about €37, and the average time from 959 to 408 ms. The numbers come from example prices and an example formula, but the order of the fixes is real.
See it
The bill, fix by fix
function day(questions, budget, cache, route) { const answers = new Map(); let euro = 0; let ms = 0, hits = 0, onSmall = 0; for (const question of questions) { const k = key(question); // a hit: no model if (cache && answers.has(k)) { hits++; ms += CACHE_MS; continue; } const model = route ? pickModel(question) : LARGE; const docs = budget ? relevant(question) : ALL_DOCS; const maxTokens = budget ? 200 : 1024; const msg = { role: "user", content: question }; const r = CLIENT.create({ model, max_tokens: maxTokens, system: PROMPT + " " + docs, messages: [msg], }); const m = MODELS[model]; euro += cost(m, r.usage); ms += latency(m, r.usage); if (model === SMALL) onSmall++; // an order belongs to one customer: no cache if (cache && !k.includes("order")) answers.set(k, r.text); } return { euro, ms, hits, onSmall };}Step by step, in words
- One call: the prompt, all the documents and the question go in (358 tokens), the answer comes out (28). The documents are most of the bill: €0.00188.
- The token budget: only the section the question asks about, and a ceiling on the answer. 144 tokens in, €0.00102 for the same answer.
- A day of 20 questions, per 1000 questions: €1.85 with all the documents, €1.05 with the budget. The time barely moves: it is mostly the answer.
- The cache: 7 of 20 questions are a repeat and do not reach the model. Questions about an order are never cached.
- The small model takes the easy topics the test set approved: 6 of 20 questions. It is cheaper and faster.
- From €1.85 to €0.42 per 1000 questions (78% less) and from 959 to 408 ms. The prices and times are examples.
The idea
Run it at your place
The real reply says how many tokens it used. Here is the request with the budget for the delivery question, and the reply. The text and the counts come from the fake client:
{ "model": "<model name>", "max_tokens": 200, "system": "<the system prompt>", "messages": [ { "role": "user", "content": "How much does delivery cost?" } ]}{ "content": [ { "type": "text", "text": "Delivery to an address is €4.90, to an office €3.50, and free over €50." } ], "stop_reason": "end_turn", "usage": { "input_tokens": 144, "output_tokens": 28 }}The fields are usage.input_tokens and usage.output_tokens, and stop_reason says whether the answer is whole (end_turn) or the ceiling cut it (max_tokens). With them the program computes the same bill as here, only with real counts.
Providers ship official SDKs for Java, C#, Python and JavaScript with the same shape: messages, roles, tokens, tools. The exact class and method names are in the provider's documentation. Here the request goes through the language's standard HTTP client, without an SDK. The address and the model are placeholders:
// display only: the address and the key header are in the provider docsconst ENDPOINT = "https://<provider>/...";
async function ask(system, question) { const response = await fetch(ENDPOINT, { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + process.env.LLM_API_KEY, }, body: JSON.stringify({ model: "<model name>", max_tokens: 200, system, messages: [{ role: "user", content: question }], }), }); return response.json(); // JSON: content, stop_reason, usage}
const reply = await ask(PROMPT + " " + relevant(question), question);const { input_tokens, output_tokens } = reply.usage;console.log(reply.stop_reason, input_tokens, output_tokens);This code does not run in the site's code check. The key is in the environment variable LLM_API_KEY, not in the code (the lesson "Secrets in code and in git"), you need an account with a provider, and every run costs money.
fetch is built into Node.js 18 and later and returns a Promise, hence await.The idea
When it hurts
- A budget that cuts what is needed. If
relevantmisses the section with the answer, the model says “I do not know” or, worse, invents. Amax_tokensthat is too low gives half an answer. Every change to the budget goes through the test set. - A cache that lies. The delivery price changes, and the cache gives the old one. A cache has an expiry (TTL) and is cleared when the documents change. Answers that depend on the customer or on the time do not go into it.
- A small model without a set. “It looks simple” is not an argument. The small model takes a topic only after the set has passed it, and the set runs again on every change.
- Saving that is not worth it. If the assistant gets 50 questions a day, the bill is a few euro a month. The complexity of the cache and the router may cost more than that. Measure first.
More precisely, if you are curious
Caching the start of the prompt. Many providers keep the repeated start of the prompt (the system prompt and the documents) and charge less for reading it again. This is a different cache: the model still answers, but the input is cheaper. It works if the start is identical to the last character, so the changing parts (the question, the customer’s name) go at the end.
A cache by meaning. “How do I return the goods?” and “Until when can I return the goods?” are different keys. A cache with vectors (the lesson on vectors) treats them as one question. It finds more hits, and wrong ones too: “Can I return shoes?” and “Can I return a gift?” are close, and the answers may differ.
A streamed answer. The answer can arrive word by word (streaming). The total time is the same, but the customer sees the first words after about a hundred ms instead of after a second. This is speed for the person, not for the bill.
Batches. Work that no customer waits for (summarising 10,000 reviews overnight) can go through a batch mode: slower and cheaper.
Time in production. The average hides the tail: 95% of the answers may be under a second, and 5% over 5 seconds. Measure percentiles, as in the lesson "Non-functional requirements you can measure" from “Software engineering”. Watching cost and time in production is part of MLOps and is taught in ZeroToOps.
[LARGE]: {...} in an object is a computed key: the property name is the value of LARGE.Exercise
Which fix saves the most
Solve it in your editor.
The program applies the fixes cumulatively, so you cannot see how much each gives alone. Run day three times, each time with only one flag, and print the rows next to the one without fixes:
large model, all documents: €1.85 per 1000 questions, 959 ms on averagebudget only: €1.05 per 1000 questions, 939 ms on averagecache only: €1.20 per 1000 questions, 622 ms on averagesmall model only: €0.92 per 1000 questions, 577 ms on averageOn its own the small model is the cheapest, and the budget comes close after it. The budget hardly saves time, and the cache and the small model cut it the most, because they do not wait for the large model to write. The three save in different places, so together they give the most.
More tasks
- One more easy topic. Add “open” to the easy topics. How much does the cost drop? What do you have to do before you ship it? (Hint: the lesson on evaluation.)
- A ceiling for the day. Stop asking the large model once the cost for the day passes €0.01, and answer “I will connect you with a colleague”. How many questions are left without an answer from the model?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Text in pieces: tokens · The context window · Calling a model through an API · Evaluation: a test set for the assistant
- Continues in: Chunking documents · RAG: answers from the documents · Evaluating RAG: does it find it, does it stand on it · The agent loop: plan, action, observation
- Tasks on this topic: Apps with LLMs