🦙
Open Source Track — Mistral/Ollama Version All code uses the 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 PromptBudget class that tracks usage, checks fit, and truncates conversation history
  • Measure tokens-per-second for your local hardware using a TokenTimer wrapper
  • Apply the "keep system prompts under 500 tokens" rule and know when to summarize long histories

What Is a Token?

Everyday Analogy

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.

Technical Definition

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:

  1. 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"].
  2. 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.
  3. 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.

Why It Matters

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

Animation 1 — BPE 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:

Two libraries, two use cases: 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)

WHAT

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.

WHY

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.

GOTCHA

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
Expected Output:
4 tokens | ['Hello', ',', ' world', '!'] text: 'Hello, world!' 3 tokens | ['token', 'ization', ''] ← wait, actually 3 subwords text: 'tokenization' 5 tokens | ["don", "'t", ' use', ' pseudo', 'code'] text: "don't use pseudocode" 4 tokens | ['Chat', 'G', 'PT', ' is one token'] ← ChatGPT = 3 tokens text: 'ChatGPT is one token' 5 tokens | ['from', ' open', 'ai', ' import', ' OpenAI'] text: 'from openai import OpenAI' Chat token estimate: 31

Mistral SentencePiece Tokenizer (Exact Count)

WHAT

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.

WHY

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.

GOTCHA

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);
Expected Output (approximate):
Text tiktoken Mistral diff -------------------------------------------------------------- 'Hello, world!' 4 4 0 'tokenization' 3 3 0 "don't use pseudocode" 5 6 +1 'ChatGPT is one token' 4 5 +1 'from openai import OpenAI' 5 5 0
What Just Happened? You ran the same 5 strings through two different tokenizers and observed that they're close but not identical. The differences come from different BPE merge histories — tiktoken was trained on a different corpus slice than Mistral's SentencePiece. For English prose the difference is under 5%. For code or mixed-language text it can reach 20%. The practical takeaway: use tiktoken for fast estimates during development; swap to the exact tokenizer for production context-limit checks.

Context Windows Explained

Technical Definition

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
Why "Practical Limit" is Less Than Advertised

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

Animation 2 — Context Window Filling & Sliding
System prompt
History
User
New tokens
Tokens in window: 0 / 32

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.

Bridge to next section: The context window is a finite resource. Every token you spend on a verbose system prompt is a token you can't use for conversation history or for the model's output. This leads directly to the need for prompt budgeting — a proactive strategy for tracking, reserving, and managing tokens before you hit the limit.

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.

Everyday Analogy

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

WHAT

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.

WHY

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.

GOTCHA

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);
What Just Happened? 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.
Animation 3 — Prompt Budget Filling
System Prompt 0 tokens
Conversation History 0 tokens
Current Message + Tools 0 tokens
Total Budget Used 0 / 32,000 tokens

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
Practical Rules of Thumb
  • 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

WHAT

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.

WHY

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.

GOTCHA

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);
Expected Output (RTX 3090 example):
Warming up... Benchmark results: Approx Input Prompt Tok Comp Tok Elapsed Decode tok/s ------------------------------------------------------------ 100→ 98 64 0.74s 86.3 500→ 492 64 1.12s 57.1 1000→ 987 64 1.68s 38.1
What Just Happened? The benchmark confirms the rule: a 10x longer prompt (100 vs 1,000 tokens) takes 2.3x longer to decode. That's the attention cost — the model has to read all 987 prompt tokens before generating each output token. On an M2 MacBook those numbers will be roughly 2–3x slower; on an RTX 4090 roughly 1.5x faster. Run this benchmark on your hardware once so you know your latency budget ceiling.

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:

Expected output format
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.

Knowledge Check

1. A developer runs tiktoken on the string "don't" and sees 2 tokens: ["don", "'t"]. Why does "'t" get its own token instead of being part of "don't" as a single unit?

A
Because "don't" is too long to fit in the vocabulary
B
"'t" also appears in "can't", "won't", "isn't" — it's reusable across many contractions, so it earns its own token in BPE training
C
The apostrophe character forces a split in all tokenizers
D
tiktoken always splits at punctuation boundaries

2. Your Mistral 7B context window is 32,000 tokens. Your system prompt is 600 tokens, you want to reserve 1,000 tokens for output, and your conversation history currently uses 28,000 tokens. What should your agent do?

A
Send the request — 600 + 28,000 + 1,000 = 29,600, which is under 32,000
B
Compress the system prompt to free up space for more history
C
Truncate the oldest history messages to make room, then send the request — the model is near the practical ~28K usable limit where quality degrades
D
Switch to a 128K context model immediately

3. A tiktoken count gives 850 tokens for a message. A Mistral SentencePiece tokenizer gives 920 tokens for the exact same message. Which tokenizer should you use for production context-limit checks, and why?

A
tiktoken — it's faster and the difference is small enough to ignore
B
The Mistral tokenizer — for hard limit checks you want the count the model itself sees. The 70-token difference (8%) could flip a "fits" decision to an error at context boundaries
C
Neither — use character count / 4 as a rough estimate
D
It doesn't matter — both counts are stored in the API response anyway

4. Your agent's system prompt is currently 2,400 tokens. You rewrite it to be equally clear in 480 tokens. On a benchmark showing 95 tok/s at 1K context and 60 tok/s at 10K context, how does this change affect a 100,000-call/day workload?

A
No effect — system prompt tokens are cached by Ollama and don't count toward latency
B
Minimal effect — 1,920 tokens difference is negligible at scale
C
Each call processes ~1,920 fewer prompt tokens, reducing time-to-first-token. At 100K calls/day, that compounds into significant GPU-hour savings and lower latency per call
D
The effect depends entirely on the model's temperature setting

5. In the PromptBudget.truncate_history() method, why is it important to preserve the first message if its role is "system", rather than dropping it like any other old message?

A
The OpenAI SDK requires a system message or it throws an error
B
System messages are cached and don't count toward the token limit
C
The system message defines the agent's role, constraints, and tools. Dropping it means the model loses all behavioral context — it might ignore safety rules, use the wrong persona, or forget it's supposed to use specific tools
D
It's a convention, not a technical requirement — you can drop it if you need the space

6. Why does the "advertised" context window of a model (e.g., 128K) differ from its "practical limit"? Select the best complete answer.

A
Because marketing teams inflate numbers
B
Only because you need to reserve output tokens
C
Three reasons: (1) attention quality degrades near context boundaries for models trained on shorter sequences; (2) VRAM for the KV cache runs out, causing CPU offloading; (3) you must reserve output headroom for max_tokens
D
The limit only applies to non-English text