Skip to content

Notebooks for LLM Work Without the Usual Mess

10 min read · updated August 4, 2026

A notebook is the right tool for looking at what a model does to your data, and the wrong tool for everything after that. Three additions — a spend guard, a disk cache and an escape route into a module — keep the first without acquiring the second.

What actually goes wrong

Not vague “notebooks are bad practice” complaints. Four specific failures, each with a specific remedy below.

  • Money leaves without a number attached. Re-running a cell that loops over a dataframe is a full re-run of the job. Nobody budgets for exploration, so nobody notices until the invoice.
  • The state is invisible. Out-of-order execution means the variable in memory may have been produced by a cell that no longer exists. A notebook that runs top-to-bottom in a fresh kernel is the only kind that means anything.
  • The prompt is stuck in the notebook. The version that worked is in cell 34 of an untracked file, and the service uses a different one that somebody retyped.
  • Outputs get committed. Keys, customer data and fifteen megabytes of base64 images, in a file whose diff nobody reads.

A spend guard in the first cell

A counter that raises when the notebook has spent more than you decided it could. This is the single highest-value cell in an LLM notebook, because it converts an unbounded mistake into a stack trace.

# cell 1 — the guard
import os

from dotenv import load_dotenv

load_dotenv()

BUDGET_UNITS = 2.00            # currency units this notebook may spend
PRICE_IN_PER_MTOK = 0.15       # <- from your provider's pricing page
PRICE_OUT_PER_MTOK = 0.60

_spent = 0.0
_calls = 0


class BudgetExceeded(RuntimeError):
    pass


def record(usage: dict) -> None:
    global _spent, _calls
    _spent += (usage.get("prompt_tokens", 0) / 1e6) * PRICE_IN_PER_MTOK
    _spent += (usage.get("completion_tokens", 0) / 1e6) * PRICE_OUT_PER_MTOK
    _calls += 1
    if _spent > BUDGET_UNITS:
        raise BudgetExceeded(
            f"notebook has spent {_spent:.2f} over {_calls} calls, "
            f"budget is {BUDGET_UNITS:.2f}. Raise BUDGET_UNITS to continue."
        )


def spend_report() -> str:
    return f"{_calls} calls, {_spent:.4f} spent, {BUDGET_UNITS - _spent:.4f} left"

Call record(body["usage"]) after every request and put spend_report() at the end of any cell that loops. The raise is deliberately unhandled: a warning in a notebook scrolls past, an exception stops the loop.

Two extra habits belong with it. Test every loop on df.head(5) first, always, and make the sample size a variable at the top rather than an edit inside the loop. And put an explicit max_tokens on every call — the default on some endpoints is the model’s maximum, which is how a five-cent experiment becomes five euros.

Cached cells

The reason notebooks cost money is that you re-run cells constantly, and nine times in ten the inputs have not changed. A disk cache keyed on the request makes the tenth run free and, more importantly, instant.

# cell 2 — a cache that survives kernel restarts
import hashlib
import json
import pathlib

CACHE_DIR = pathlib.Path(".llm-cache")
CACHE_DIR.mkdir(exist_ok=True)


def _key(payload: dict) -> str:
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:24]


def call(payload: dict, *, use_cache: bool = True) -> dict:
    path = CACHE_DIR / f"{_key(payload)}.json"
    if use_cache and path.exists():
        return json.loads(path.read_text(encoding="utf-8"))

    response = client.post("/chat/completions", json=payload)
    response.raise_for_status()
    body = response.json()

    record(body.get("usage", {}))            # only real calls count against budget
    path.write_text(json.dumps(body), encoding="utf-8")
    return body

Surviving a kernel restart is what distinguishes this from functools.lru_cache, and kernel restarts are frequent — they are the correct response to confusing state. Add .llm-cache/ to .gitignore, and remember that the cache now contains every prompt and response, which is a data-retention question if the prompts contain anything sensitive.

The use_cache=False escape hatch matters for the case where you want to see the variation between samples at a high temperature. The cache is keyed on the payload, so identical requests return identical answers — correct for reproducibility, wrong when variance is the thing you are studying. The fuller version of this is in caching model responses in Python.

Getting the code out into a module

The single change that most improves a notebook is moving the functions into a .py file next to it and importing them. autoreload makes that painless: edit the module in your editor, re-run the cell, and the new definition is live without restarting the kernel.

# cell 0 — before any other import
%load_ext autoreload
%autoreload 2

from mylib.prompts import CLASSIFY_SYSTEM, build_messages
from mylib.parsing import parse_json_output

What moves out, in order of how much it helps:

  1. Prompts. First, always. A prompt in a module is diffable, greppable, importable by the service and by a test, and reviewable in a pull request. A prompt in a cell is none of those.
  2. Parsing and validation. These are the functions with edge cases, which means they are the functions that want tests, which means they cannot live in a notebook.
  3. The client and the call wrapper. So the notebook and production send byte-identical requests. If they diverge, the notebook’s findings do not transfer.
  4. Nothing else, yet. Plotting, slicing and eyeballing are what the notebook is for. Moving those out too early is how people conclude notebooks are not worth using.
autoreload re-executes module code but cannot re-bind everything: objects created before an edit keep their old class, and changes to class hierarchies or to @dataclass definitions often need a kernel restart. When behaviour stops matching the source you are reading, restart before debugging — it is the explanation surprisingly often.

Hygiene that costs nothing

PracticeDescription
nbstripout --installA git filter that removes cell outputs on commit while leaving your working copy alone. Prevents the committed-key and committed-customer-data cases at once, and makes notebook diffs readable.
Restart and run all, before you believe itThe only execution order that is reproducible. A result that does not survive a fresh kernel is not a result, it is an artefact of your session.
One notebook, one questionA 400-cell notebook covering four investigations cannot be re-run and will not be read. Name them for their question and let them end.
Pin the model in a constantMODEL = "..." at the top, referenced everywhere. A notebook that used three models in different cells cannot be compared with anything, including itself.
jupytext or nbconvert --to scriptPairs the notebook with a plain .py file, or exports one. Worth it when the notebook is becoming a pipeline; unnecessary while it is still exploration.

When the notebook has served its purpose

There is a specific moment, and it is easy to miss because nothing breaks: the first time somebody other than you needs the result. Before that, a notebook is a personal instrument. After it, the notebook is a dependency on your laptop and your memory of which cells to run.

Three usual destinations, depending on what the notebook turned out to be:

  • A repeatable job — a script with a checkpoint and a resume, as in classifying 50,000 rows.
  • A command somebody else runs — a CLI with config resolution and a dry run, as in packaging an AI script as a CLI.
  • An evaluation — a fixed input set, a scoring function and a number that can be compared across prompts and models. That is a different discipline; building an eval harness is where it goes.

Keep the notebook afterwards, committed with its outputs stripped. It is the record of why the prompt is shaped the way it is, and that reasoning is otherwise lost the moment the code is tidy.