openai SDK pointing at a local Ollama server. No Anthropic API key required. OS Track Index →
M02: Tokens & Context Limits
Understand the currency of every LLM interaction — tokens — and learn to count them, budget them, and manage context windows before they manage you.
Learning Objectives
- Explain what a token is and how Byte Pair Encoding (BPE) splits text into subword units
- Count tokens using tiktoken and the Mistral sentencepiece tokenizer, and explain why counts differ
- Read a context window limits table and choose the right local model for your task's token budget
- Build a
PromptBudgetclass that tracks usage, checks fit, and truncates conversation history - Measure tokens-per-second for your local hardware using a
TokenTimerwrapper - Apply the "keep system prompts under 500 tokens" rule and know when to summarize long histories
What Is a Token?
BEFORE: You might assume LLMs read your text character-by-character (like typing one key at a time) or word-by-word (like a human reading a sentence). Both feel intuitive because those are the units you experience language in.
PAIN: Neither is right, and that mismatch causes real surprises. If LLMs processed characters, "hello" would cost 5 units and "hi" would cost 2 — but actually they cost similar amounts. If LLMs processed whole words, every new word in the training data would need its own slot, which is impossible at scale. "ChatGPT" is exactly 1 token. "tokenization" is 3 tokens. "supercalifragilistic" is 6. Your intuition about word length and token count are completely uncorrelated.
MAPPING: Tokens are to LLMs what syllables are to reading speed. A skilled reader doesn't decode letters one at a time, nor do they pause at every word boundary — their brain chunks text into familiar sound-units. LLMs do the same thing but with statistical units: subword pieces that appeared frequently enough in the training corpus to get their own ID. "playing" might be one token. "tokenization" splits into ["token", "ization"]. An emoji is one token. A rare Welsh word might be 8 tokens. The model learned in these chunks, so it thinks in these chunks.
A tokenThe smallest unit of text an LLM works with. Tokens are learned during training via a tokenization algorithm like BPE. A token can be a whole word, a word-piece, punctuation, whitespace, or even a single character for rare strings. is the smallest discrete unit that an LLM's vocabulary contains. Every model ships with a fixed vocabulary — a lookup table mapping token strings to integer IDs (e.g., "hello" → 15339). During training, all text is first converted to a sequence of these integer IDs; during inference, input text is tokenized and output IDs are decoded back to text.
Modern LLM vocabularies contain 32,000–128,000 tokens. Larger vocabularies mean more words get a single token (better efficiency) but require more memory. Mistral 7B uses 32,000 tokens (SentencePiece); LLaMA 3 uses 128,256. The same English sentence can have different token counts in different models.
How BPE Builds a Vocabulary
BPEByte Pair Encoding — a compression algorithm adapted for tokenization. It starts with individual characters, then iteratively merges the most frequent adjacent pair until the vocabulary reaches the target size. (Byte Pair Encoding) is the algorithm behind most modern tokenizers. Here's how it works in three steps:
- Start with characters: Split every word in the training corpus into individual characters (plus a special end-of-word marker). At this point, "hello" = ["h", "e", "l", "l", "o"].
- Count adjacent pairs: Find the most frequently co-occurring pair across the entire corpus. If "he" appears 1 million times together, merge them: "he" becomes a single token.
- Repeat until vocabulary is full: Keep merging the most frequent pairs until the vocabulary reaches its target size (e.g., 32,000). The result is a vocabulary of characters, common subwords, and whole words.
This explains why "don't" → ["don", "'t"] — the tokenizer learned that "don" and "'t" are more useful as separate reusable pieces, since "'t" also appears in "can't", "won't", "isn't". But "hello" → ["hello"] because it's common enough as a whole unit to earn its own token slot.
Understanding BPE explains cost surprises. A 500-word business email might be 600 tokens. The same email in German might be 700 tokens (German compound words get split more). Code with lots of rare variable names like myUnusualFunctionName can balloon token counts by 3–4x compared to terse code. In production agents with 10,000 API calls/day, a 30% token reduction from concise prompts is the difference between a $50/day and $35/day infrastructure bill — even with local models, that maps directly to GPU compute time and latency.
Animation: Token Chunking
Notice how whitespace is often bundled with the following word (e.g., " world" is one token, not " " + "world"). This is a deliberate BPE design choice — leading spaces help the model understand word boundaries without requiring a separate space token.
Counting Tokens with tiktoken and the Mistral Tokenizer
In production you need to count tokens before sending a request — not after. There are two tokenizer libraries you'll reach for in the local-model world:
tiktoken is OpenAI's fast Rust-backed tokenizer. Because Ollama's OpenAI-compatible API uses similar tokenization vocabulary for many models, tiktoken gives a close approximation (within 5–10%) for Mistral 7B. The transformers library contains the exact Mistral SentencePiece tokenizer — more accurate, but slower and requires downloading a model config. For quick estimates: tiktoken. For production budgeting: the exact tokenizer.
tiktoken (OpenAI Tokenizer)
Load the cl100k_base encoding (used by GPT-4 and close to Mistral's vocabulary). Call encode() to get a list of integer token IDs. len() of that list is your token count.
cl100k_base has a 100K-token vocabulary, overlapping significantly with Mistral's 32K vocabulary for common English text. The error rate is low enough for budget estimates, and tiktoken encodes 10MB of text per second — fast enough for real-time pre-flight checks.
tiktoken counts chat message overhead differently from the raw text. System messages, role markers, and separator tokens add 3–5 extra tokens per message turn. The full formula is shown in the multi-message example below.
# pip install tiktoken
import tiktoken
# Load the cl100k_base encoding (GPT-4 / close to Mistral)
enc = tiktoken.get_encoding("cl100k_base")
# ── Basic token counting ────────────────────────────────────────
samples = [
"Hello, world!",
"tokenization",
"don't use pseudocode",
"ChatGPT is one token",
"from openai import OpenAI",
]
for text in samples:
ids = enc.encode(text)
tokens = [enc.decode_single_token_bytes(t).decode("utf-8", errors="replace")
for t in ids]
print(f"{len(ids):3d} tokens | {tokens!r}")
print(f" text: {text!r}")
print()
# ── Chat message overhead ──────────────────────────────────────
# OpenAI-style chat adds 3 tokens per message + 3 for the reply primer
def count_chat_tokens(messages: list[dict]) -> int:
"""Count tokens for a list of chat messages including overhead."""
total = 3 # primer for assistant reply
for msg in messages:
total += 4 # role, content field markers, sep
total += len(enc.encode(msg.get("role", "")))
total += len(enc.encode(msg.get("content", "")))
return total
messages = [
{"role": "system", "content": "You are a helpful coding assistant."},
{"role": "user", "content": "Explain what a token is in 2 sentences."},
]
print(f"Chat token estimate: {count_chat_tokens(messages)}")
# Output: Chat token estimate: ~31
// npm install js-tiktoken
import { get_encoding } from "js-tiktoken";
// Load the cl100k_base encoding
const enc = get_encoding("cl100k_base");
// ── Basic token counting ────────────────────────────────────────
const samples = [
"Hello, world!",
"tokenization",
"don't use pseudocode",
"ChatGPT is one token",
"from openai import OpenAI",
];
for (const text of samples) {
const ids = enc.encode(text);
console.log(`${ids.length} tokens | "${text}"`);
}
// ── Chat message overhead ──────────────────────────────────────
interface ChatMessage { role: string; content: string; }
function countChatTokens(messages: ChatMessage[]): number {
let total = 3; // primer for assistant reply
for (const msg of messages) {
total += 4; // role + content markers + sep
total += enc.encode(msg.role).length;
total += enc.encode(msg.content).length;
}
return total;
}
const messages: ChatMessage[] = [
{ role: "system", content: "You are a helpful coding assistant." },
{ role: "user", content: "Explain what a token is in 2 sentences." },
];
console.log(`Chat token estimate: ${countChatTokens(messages)}`);
// Output: Chat token estimate: ~31
enc.free(); // release WASM memory
Mistral SentencePiece Tokenizer (Exact Count)
Use HuggingFace transformers to load the actual Mistral 7B tokenizer. This is the same tokenizer that runs inside Ollama when you use mistral. The token IDs will match exactly what Ollama sees.
Mistral uses SentencePiece with a 32K vocabulary — different merge rules from tiktoken's 100K BPE. For most English text the difference is small. For code, non-English text, or strings with underscores/colons, the difference can be 15–25%. When you need precision (billing, context-limit checks), use this.
AutoTokenizer.from_pretrained() downloads ~500KB of config files on first run. They're cached in ~/.cache/huggingface/. In CI/CD or air-gapped environments, pre-download with tokenizer.save_pretrained("./local_tokenizer") and load from the local path instead.
# pip install transformers
from transformers import AutoTokenizer
# Downloads ~500KB of config from HuggingFace Hub (cached)
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
samples = [
"Hello, world!",
"tokenization",
"don't use pseudocode",
"ChatGPT is one token",
"from openai import OpenAI",
]
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
print(f"{'Text':<35} {'tiktoken':>8} {'Mistral':>8} {'diff':>6}")
print("-" * 62)
for text in samples:
tik_n = len(enc.encode(text))
mis_n = len(tokenizer.encode(text, add_special_tokens=False))
diff = mis_n - tik_n
sign = "+" if diff > 0 else ""
print(f"{text!r:<35} {tik_n:>8} {mis_n:>8} {sign+str(diff):>6}")
// npm install @xenova/transformers js-tiktoken
// Note: loads the full Mistral tokenizer via WASM — first run downloads ~2MB
import { AutoTokenizer } from "@xenova/transformers";
import { get_encoding } from "js-tiktoken";
async function compareTokenizers() {
const mistralTok = await AutoTokenizer.from_pretrained(
"mistralai/Mistral-7B-v0.1"
);
const tiktok = get_encoding("cl100k_base");
const samples = [
"Hello, world!",
"tokenization",
"don't use pseudocode",
"ChatGPT is one token",
"from openai import OpenAI",
];
console.log(
`${"Text".padEnd(35)} ${"tiktoken".padStart(8)} ${"Mistral".padStart(8)} ${"diff".padStart(6)}`
);
console.log("-".repeat(62));
for (const text of samples) {
const tikN = tiktok.encode(text).length;
const misN = mistralTok.encode(text, { add_special_tokens: false }).length;
const diff = misN - tikN;
const sign = diff > 0 ? "+" : "";
console.log(
`${text.padEnd(35)} ${String(tikN).padStart(8)} ${String(misN).padStart(8)} ${(sign + diff).padStart(6)}`
);
}
tiktok.free();
}
compareTokenizers().catch(console.error);
Context Windows Explained
The context windowThe total number of tokens a model can process in a single inference call — including the system prompt, all conversation history, tool definitions, and the model's output. Think of it as the model's working memory. is the combined token budget for everything the model can see during one inference call: system prompt + conversation history + tool definitions + the current message + the model's output. It is a hard ceiling baked into the model architecture at training time. Once you exceed it, the API returns an error or silently truncates input.
The attentionThe mechanism in a transformer that lets every token "look at" every other token in the context. Attention cost scales quadratically with sequence length — doubling the context roughly quadruples the compute needed, which is why long contexts are slower and more memory-intensive. mechanism inside a transformer reads every token in context simultaneously. This is why inference on a 128K context is much slower than on a 2K context — the model is running O(n²) operations where n is the token count.
Context Limits for Popular Local Models
| Model | Advertised Context | Practical Limit | Notes |
|---|---|---|---|
| Mistral 7B | 32K | ~28K usable | Sliding Window Attention; quality degrades near edges |
| Llama 3.1 8B | 128K | ~100K usable | RoPE scaling; slower above 32K on consumer GPUs |
| Qwen 2.5 7B | 128K | ~100K usable | YARN context extension; best for long documents |
| Phi-3 Mini | 128K | ~90K usable | 3.8B params; fast inference; excellent on M2 MacBook |
| Gemma 2 9B | 8K | ~7K usable | Sliding attention; build in summarization checkpoints |
Three reasons the real limit is lower than the spec sheet says:
- Attention degradation at boundaries: Models trained on shorter sequences with RoPERotary Position Embedding — a technique that encodes token positions so the model can generalise to sequences longer than it saw during training. Quality drops when you far exceed the training length. or sliding attention can lose coherence near the end of very long contexts. The model "forgets" earlier content or starts hallucinating connections that aren't there.
- Memory pressure: A 128K context on an 8B model requires ~24GB of VRAM for the KV cache alone. Most consumer GPUs have 8–24GB. Exceeding VRAM capacity causes CPU offloading, which tanks tokens-per-second by 10–50x.
- You need output headroom: The advertised limit is input + output combined. If you fill 128K tokens with input and set
max_tokens=2048, the model returns an error. Reserve at least 1K–4K for output.
Animation: Sliding Context Window
Notice that when the window fills up, old history tokens drop off the left side — the model can no longer see them. This is why naive "append every message to a list" conversation management eventually fails: you either hit an error or the model loses the context of what was said many turns ago.
Prompt Budgeting
In production agents, you must track token usage across the conversation and plan ahead. Waiting until the API throws a context-length error is like driving until you run out of gas: technically preventable, embarrassing in production.
BEFORE: When you book travel, you don't just assume you have enough budget — you add up flights, hotel, food, and activities before confirming the reservation.
PAIN: Agent builders often append every message to a list and only discover the context limit when an API call fails mid-conversation — usually in front of a user.
MAPPING: A PromptBudget is your travel spreadsheet. Before every API call, check: "Do I still fit?" If not, drop the oldest history messages (like cancelling a hotel night), keeping the system prompt (the flight you've already paid for) and the most recent context.
The PromptBudget Class
PromptBudget wraps tiktoken to provide four methods: estimate token count for any string, compute how many tokens remain given a message list, check whether a new message fits, and truncate history to fit within a budget while preserving the system prompt.
Centralizing token math in one class means every agent component uses the same estimates. It prevents the bug where one module uses character-based estimates (off by 3–4x) and another uses word counts (off by 1.3x). A shared PromptBudget gives you one source of truth.
truncate_history preserves the system message (index 0) and removes turns from oldest to newest. It does not summarize — for agents that need to remember dropped content, wire in a summarization step before truncation. That's covered in M08: Conversation Management.
# pip install tiktoken openai
from __future__ import annotations
import tiktoken
from openai import OpenAI
# ─────────────────────────────────────────────────────────────────
# CHUNK 1: PromptBudget class definition
# ─────────────────────────────────────────────────────────────────
class PromptBudget:
"""Track and manage token usage across a conversation."""
# Per-message overhead in tiktoken cl100k_base format
TOKENS_PER_MESSAGE = 4 # role + content markers + sep
TOKENS_REPLY_PRIMER = 3 # assistant reply primer tokens
def __init__(self, model: str = "mistral", max_context: int = 32_000):
self.model = model
self.max_context = max_context
# Use cl100k_base as a close approximation for Mistral
self._enc = tiktoken.get_encoding("cl100k_base")
def estimate_tokens(self, text: str) -> int:
"""Count tokens in a plain string."""
return len(self._enc.encode(text))
def _count_messages(self, messages: list[dict]) -> int:
"""Count tokens for a messages list including overhead."""
total = self.TOKENS_REPLY_PRIMER
for msg in messages:
total += self.TOKENS_PER_MESSAGE
total += len(self._enc.encode(msg.get("role", "")))
total += len(self._enc.encode(msg.get("content", "")))
return total
def _count_tools(self, tools: list[dict] | None) -> int:
"""Rough estimate: serialize tools to JSON and count tokens."""
if not tools:
return 0
import json
return len(self._enc.encode(json.dumps(tools)))
def remaining(
self,
messages: list[dict],
tools: list[dict] | None = None,
reserve_output: int = 512,
) -> int:
"""Return how many tokens are left for new messages + output."""
used = self._count_messages(messages) + self._count_tools(tools)
return self.max_context - used - reserve_output
def fits(
self,
text: str,
messages: list[dict],
tools: list[dict] | None = None,
reserve_output: int = 512,
) -> bool:
"""True if adding `text` as a new message still fits in budget."""
new_msg_tokens = self.estimate_tokens(text) + self.TOKENS_PER_MESSAGE
return self.remaining(messages, tools, reserve_output) >= new_msg_tokens
def truncate_history(
self,
messages: list[dict],
reserve_output: int = 512,
) -> list[dict]:
"""
Drop oldest non-system messages until the conversation fits.
Always preserves the first message if it's the system prompt.
Returns a new list — does not mutate the input.
"""
result = list(messages)
# Identify system prompt boundary (first message, role=system)
start = 1 if result and result[0].get("role") == "system" else 0
while True:
tokens_used = self._count_messages(result)
headroom = self.max_context - tokens_used - reserve_output
if headroom >= 0:
break
if len(result) <= start + 1:
# Can't drop any more — just return what we have
break
result.pop(start) # Remove oldest non-system message
return result
# ─────────────────────────────────────────────────────────────────
# CHUNK 2: Usage example with Ollama
# ─────────────────────────────────────────────────────────────────
def main():
budget = PromptBudget(model="mistral", max_context=32_000)
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
system = {"role": "system", "content": "You are a concise coding assistant."}
history: list[dict] = [system]
turns = [
"What is a Python list comprehension?",
"Show me an example that squares even numbers from 1 to 20.",
"How does that compare to a regular for-loop?",
"What's the memory usage difference?",
]
for user_text in turns:
print(f"\nUser: {user_text}")
print(f" Tokens remaining: {budget.remaining(history)}")
if not budget.fits(user_text, history):
print(" [truncating history to fit]")
history = budget.truncate_history(history)
history.append({"role": "user", "content": user_text})
try:
response = client.chat.completions.create(
model="mistral",
messages=history,
max_tokens=256,
)
reply = response.choices[0].message.content
history.append({"role": "assistant", "content": reply})
print(f"Assistant: {reply[:120]}...")
except Exception as e:
print(f" Error: {e}")
# Remove the user message we just added so state stays consistent
history.pop()
if __name__ == "__main__":
main()
// npm install js-tiktoken openai
import { get_encoding, type TiktokenEncoding } from "js-tiktoken";
import OpenAI from "openai";
// ─────────────────────────────────────────────────────────────────
// CHUNK 1: PromptBudget class definition
// ─────────────────────────────────────────────────────────────────
interface ChatMessage { role: string; content: string; }
class PromptBudget {
private enc: ReturnType;
private readonly TOKENS_PER_MESSAGE = 4;
private readonly TOKENS_REPLY_PRIMER = 3;
constructor(
private model: string = "mistral",
private maxContext: number = 32_000
) {
this.enc = get_encoding("cl100k_base" as TiktokenEncoding);
}
estimateTokens(text: string): number {
return this.enc.encode(text).length;
}
private countMessages(messages: ChatMessage[]): number {
let total = this.TOKENS_REPLY_PRIMER;
for (const msg of messages) {
total += this.TOKENS_PER_MESSAGE;
total += this.enc.encode(msg.role).length;
total += this.enc.encode(msg.content).length;
}
return total;
}
private countTools(tools?: object[]): number {
if (!tools || tools.length === 0) return 0;
return this.enc.encode(JSON.stringify(tools)).length;
}
remaining(
messages: ChatMessage[],
tools?: object[],
reserveOutput = 512
): number {
const used = this.countMessages(messages) + this.countTools(tools);
return this.maxContext - used - reserveOutput;
}
fits(
text: string,
messages: ChatMessage[],
tools?: object[],
reserveOutput = 512
): boolean {
const newMsgTokens = this.estimateTokens(text) + this.TOKENS_PER_MESSAGE;
return this.remaining(messages, tools, reserveOutput) >= newMsgTokens;
}
truncateHistory(
messages: ChatMessage[],
reserveOutput = 512
): ChatMessage[] {
const result = [...messages];
const start = result[0]?.role === "system" ? 1 : 0;
while (true) {
const tokensUsed = this.countMessages(result);
const headroom = this.maxContext - tokensUsed - reserveOutput;
if (headroom >= 0) break;
if (result.length <= start + 1) break;
result.splice(start, 1); // Remove oldest non-system message
}
return result;
}
free(): void { this.enc.free(); }
}
// ─────────────────────────────────────────────────────────────────
// CHUNK 2: Usage example with Ollama
// ─────────────────────────────────────────────────────────────────
async function main() {
const budget = new PromptBudget("mistral", 32_000);
const client = new OpenAI({
baseURL: "http://localhost:11434/v1",
apiKey: "ollama",
});
const system: ChatMessage = {
role: "system",
content: "You are a concise coding assistant.",
};
let history: ChatMessage[] = [system];
const turns = [
"What is a Python list comprehension?",
"Show me an example that squares even numbers from 1 to 20.",
"How does that compare to a regular for-loop?",
"What's the memory usage difference?",
];
for (const userText of turns) {
console.log(`\nUser: ${userText}`);
console.log(` Tokens remaining: ${budget.remaining(history)}`);
if (!budget.fits(userText, history)) {
console.log(" [truncating history to fit]");
history = budget.truncateHistory(history);
}
history.push({ role: "user", content: userText });
try {
const response = await client.chat.completions.create({
model: "mistral",
messages: history,
max_tokens: 256,
});
const reply = response.choices[0].message.content ?? "";
history.push({ role: "assistant", content: reply });
console.log(`Assistant: ${reply.slice(0, 120)}...`);
} catch (err) {
console.error(` Error: ${err}`);
history.pop(); // Remove the user message to keep state consistent
}
}
budget.free();
}
main().catch(console.error);
PromptBudget wraps tiktoken so every part of your agent uses the same token estimates. Before each turn, you check budget.fits() and call budget.truncate_history() if needed. The system prompt is always preserved. Oldest history turns are dropped first. The agent never hits a context-limit error in production because it proactively manages its own memory window.
Token Cost Awareness: Local = Free, But…
With local models, tokens have no API cost. But they have a latency cost — and in agents, latency is often more critical than money. A multi-step agent loop that takes 40 seconds per turn because of a bloated 20K-token context is unusable even if it costs $0.
| Hardware | Model | Context Size | Tokens/sec |
|---|---|---|---|
| RTX 3090 (24GB) | Mistral 7B Q4 | 1K tokens | ~95 tok/s |
| RTX 3090 (24GB) | Mistral 7B Q4 | 10K tokens | ~60 tok/s |
| RTX 4090 (24GB) | Mistral 7B Q4 | 1K tokens | ~145 tok/s |
| RTX 4090 (24GB) | Mistral 7B Q4 | 10K tokens | ~90 tok/s |
| M2 MacBook Pro (16GB) | Mistral 7B Q4 | 1K tokens | ~40 tok/s |
| M2 MacBook Pro (16GB) | Mistral 7B Q4 | 10K tokens | ~22 tok/s |
- Keep system prompts under 500 tokens. A system prompt that explains "you are a helpful assistant who..." in 2,000 tokens instead of 400 tokens costs you ~15% throughput on every single call. Write tight prompts.
- A 10K-token context runs 30–50% slower than a 1K-token context on most consumer hardware. For interactive agents, target under 4K tokens per turn.
- Use summarization for long histories. When history exceeds 6K tokens, summarize the oldest half into a single "conversation summary" message instead of truncating. This keeps information alive without burning context budget.
TokenTimer: Measure Your Local Model's Speed
TokenTimer wraps an Ollama call and measures wall-clock time. It extracts prompt and completion token counts from the usage field of the response, then calculates tokens/sec for both prefill (reading your prompt) and decode (generating output) phases.
The usage field in Ollama's OpenAI-compatible API returns prompt_tokens and completion_tokens. Measuring wall clock time gives you a real-world benchmark that includes Ollama's overhead — not just the theoretical model speed.
The first request to Ollama after a cold start loads the model into VRAM, which adds 1–5 seconds. Always warm up with a short throwaway request before benchmarking, or treat the first measurement as "cold start latency" separately.
# pip install openai
import time
from dataclasses import dataclass
from openai import OpenAI
# ─────────────────────────────────────────────────────────────────
# CHUNK 1: TokenTimer dataclass + wrapper
# ─────────────────────────────────────────────────────────────────
@dataclass
class TimerResult:
prompt_tokens: int
completion_tokens: int
total_tokens: int
elapsed_seconds: float
decode_tokens_per_sec: float
content: str
class TokenTimer:
"""Wrap an Ollama call and report latency + throughput metrics."""
def __init__(self, model: str = "mistral"):
self.model = model
self.client = OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama",
)
def run(
self,
messages: list[dict],
max_tokens: int = 256,
) -> TimerResult:
"""Send a chat request and return timing metrics."""
t_start = time.perf_counter()
try:
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_tokens,
)
except Exception as e:
raise RuntimeError(f"Ollama request failed: {e}") from e
elapsed = time.perf_counter() - t_start
usage = response.usage
content = response.choices[0].message.content or ""
# decode speed = output tokens / total time
decode_tps = (usage.completion_tokens / elapsed) if elapsed > 0 else 0
return TimerResult(
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
total_tokens=usage.total_tokens,
elapsed_seconds=elapsed,
decode_tokens_per_sec=decode_tps,
content=content,
)
# ─────────────────────────────────────────────────────────────────
# CHUNK 2: Benchmark over three prompt sizes
# ─────────────────────────────────────────────────────────────────
def make_prompt(approx_tokens: int) -> list[dict]:
"""Generate a padding prompt of approximately the given token count."""
word = "benchmark "
# ~1.3 chars per token → n_chars ≈ tokens * 1.3
padding = word * (approx_tokens // 2)
return [
{"role": "system", "content": "You are a helpful assistant. Answer briefly."},
{"role": "user", "content": f"Please summarize the following:\n\n{padding}\n\nSummarize in one sentence."},
]
def main():
timer = TokenTimer(model="mistral")
# Warm-up (avoids model load time polluting benchmarks)
print("Warming up...")
_ = timer.run([{"role": "user", "content": "hi"}], max_tokens=5)
print("\nBenchmark results:")
print(f"{'Approx Input':>14} {'Prompt Tok':>10} {'Comp Tok':>9} {'Elapsed':>8} {'Decode tok/s':>13}")
print("-" * 60)
for target in [100, 500, 1_000]:
msgs = make_prompt(target)
result = timer.run(msgs, max_tokens=64)
print(
f"{target:>12}→ "
f"{result.prompt_tokens:>9} "
f"{result.completion_tokens:>9} "
f"{result.elapsed_seconds:>7.2f}s "
f"{result.decode_tokens_per_sec:>12.1f}"
)
if __name__ == "__main__":
main()
// npm install openai
import OpenAI from "openai";
// ─────────────────────────────────────────────────────────────────
// CHUNK 1: TokenTimer class
// ─────────────────────────────────────────────────────────────────
interface TimerResult {
promptTokens: number;
completionTokens: number;
totalTokens: number;
elapsedSeconds: number;
decodeTokensPerSec: number;
content: string;
}
interface ChatMessage { role: string; content: string; }
class TokenTimer {
private client: OpenAI;
constructor(private model: string = "mistral") {
this.client = new OpenAI({
baseURL: "http://localhost:11434/v1",
apiKey: "ollama",
});
}
async run(
messages: ChatMessage[],
maxTokens = 256
): Promise {
const tStart = performance.now();
let response: OpenAI.Chat.Completions.ChatCompletion;
try {
response = await this.client.chat.completions.create({
model: this.model,
messages,
max_tokens: maxTokens,
});
} catch (err) {
throw new Error(`Ollama request failed: ${err}`);
}
const elapsed = (performance.now() - tStart) / 1000; // seconds
const usage = response.usage!;
const content = response.choices[0].message.content ?? "";
const decodeTps = elapsed > 0 ? usage.completion_tokens / elapsed : 0;
return {
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
elapsedSeconds: elapsed,
decodeTokensPerSec: decodeTps,
content,
};
}
}
// ─────────────────────────────────────────────────────────────────
// CHUNK 2: Benchmark over three prompt sizes
// ─────────────────────────────────────────────────────────────────
function makePrompt(approxTokens: number): ChatMessage[] {
const padding = "benchmark ".repeat(Math.floor(approxTokens / 2));
return [
{ role: "system", content: "You are a helpful assistant. Answer briefly." },
{ role: "user", content: `Please summarize the following:\n\n${padding}\n\nSummarize in one sentence.` },
];
}
async function main() {
const timer = new TokenTimer("mistral");
// Warm-up
console.log("Warming up...");
await timer.run([{ role: "user", content: "hi" }], 5);
console.log("\nBenchmark results:");
console.log(
`${"Approx Input".padStart(14)} ${"Prompt Tok".padStart(10)} ${"Comp Tok".padStart(9)} ${"Elapsed".padStart(8)} ${"Decode tok/s".padStart(13)}`
);
console.log("-".repeat(60));
for (const target of [100, 500, 1_000]) {
const msgs = makePrompt(target);
const r = await timer.run(msgs, 64);
console.log(
`${(target + "→").padStart(14)} ` +
`${String(r.promptTokens).padStart(9)} ` +
`${String(r.completionTokens).padStart(9)} ` +
`${r.elapsedSeconds.toFixed(2).padStart(7)}s ` +
`${r.decodeTokensPerSec.toFixed(1).padStart(12)}`
);
}
}
main().catch(console.error);
Exercises
Exercise 1: Token Breakdown by Paragraph
Write a script that reads any text file and prints a token count breakdown by paragraph. Use tiktoken. Output should look like:
Paragraph 1 (3 sentences): 47 tokens
Paragraph 2 (5 sentences): 112 tokens
Paragraph 3 (2 sentences): 38 tokens
───────────────────────────────────────
Total: 197 tokens across 3 paragraphs
Avg per paragraph: 65.7 tokens
Bonus: Add a flag to also show the actual token strings for each paragraph (color each paragraph's tokens a different color using ANSI escape codes).
Exercise 2: Auto-Truncating Conversation Manager
Build a ConversationManager class that wraps PromptBudget and:
- Adds each turn to history and checks budget after each assistant reply
- When history exceeds 80% of context, automatically calls
truncate_history() - Logs a warning when truncation occurs:
"[Truncated: dropped N turns, kept system + last M turns]" - Exposes a
token_utilization()method returning a float 0.0–1.0
Test it by simulating a 20-turn conversation where each turn adds ~400 tokens of history, against a 4,000-token context limit.
Exercise 3: Tokens-per-Second Benchmark Suite
Extend TokenTimer to run a full benchmark suite and produce a CSV report:
- Test prompt lengths: 100, 500, 1,000, 5,000 tokens (skip 5K if your GPU has < 8GB VRAM)
- Run each length 3 times and report mean ± std-dev
- Write results to
benchmark_results.csv - Print a summary table with a sparkline bar (use ASCII
▁▂▃▄▅▆▇█chars) showing relative decode speed
Stretch goal: Benchmark two different models (mistral and phi3) side-by-side and compare their tokens/sec curves.