Dialogue · Design notes · Prompt caching
← Design notes

How prompt caching works

Each turn of the interactive panel re-sends the same large prefix — the megaprompt instructions plus each persona's profile (a room has a per-room cast; the room is scene-less, so there are no briefs, and the running transcript is the only memory — no journals). Caching lets you pay for that once and read it cheap on every turn after. Here's the mechanic, then the two ways to handle the deliberation. This is the mechanic; the cost trade-offs (1h vs 5-min, write multiples, break-even) live in the economics companion, Cache TTL economics.

A cache hit runs from the very first token until the first byte that differs — then it stops. It's a prefix, from the front. Always. Not scattered identical blocks.
product explainerEvery room has a per-room cast — one or more humans plus the personas seated in that room. The prefix rule, TTL, keep-vs-strip confer, the live add/remove of a voice (§8), and compaction (§9) are all product mechanics. §1–7 are the steady-state mechanic; the live-cast add/retire of §8 is shipped (mid-room invite/retire), and §9 compaction is the next build.
written once (~1.25× input) cached read (~0.1× — the win) new, full price this turn generated output (full price, once) cache miss — re-read

1 · The model reads one long sequence

Everything you send is one stream, read front to back. Caching looks for the longest run that is identical to a previous request, starting at the very front.

system · megaprompt + the room's cast profiles conversation so far new user line

The system block is the bulk of the tokens and never changes. That's the prize: re-reading it for free every turn.

2 · TTL — how long the cache lives

1 hour
TTL = "time to live." The app defaults to a 1-hour TTL: the cached prefix survives an hour, and the clock resets on every hit — so a deliberative human room (where people read, think, and step away between turns) stays warm across the long gaps that would let a short cache lapse. A 5-minute TTL exists at a lower write price as the cheaper-but-shorter alternative — better for fast back-and-forth where nobody pauses. Why 1h is the default (and the break-even math): Cache TTL economics.

3 · It's a prefix, not scattered blocks

Byte-identical isn't enough. A block also has to sit in the same place with everything before it unchanged. Change something in the middle and the cache stops there — even if later blocks are identical, they come after the change, so they're re-read.

call A
systemblock Bblock C
call B — B changed; C is byte-identical to before
system block B′ (changed) block C — re-read

C is a miss even though it didn't change, because the hit ended at B. Order and position matter as much as content.

4 · So: stable first, volatile last

Put the things that never change at the front, and the things that change every turn at the end. Anything volatile placed early pushes the "first differing byte" forward and throws away the cache for everything after it.

system + character materials  — never changes  → stays cached conversation history  — append-only  → stays cached (grows) new user line  — changes every turn  → the only full-price input

5 · Keep the confer, or drop it — both still cache

After the panel deliberates backstage, you choose what to store in the history for next turn: the whole confer, or only the public responses. Watch the same blocks turn gold (new) once, then green (cached) forever after. The two options differ only in how big the cached block is — never in hit-vs-miss.

KEEP confer — the back-and-forth stays in history

turn 1
systemu₁ gen: confer₁ + resp₁
turn 2
systemu₁ · [confer₁ + resp₁]u₂ gen₂
turn 3
systemu₁·[confer₁+resp₁]·u₂·[confer₂+resp₂]u₃ gen₃

Bigger cached block — the panel "remembers" how it argued. All cheap reads.

DROP confer — confer goes to the drawer, only responses stored

turn 1
systemu₁ gen: confer₁ + resp₁

confer₁ → side panel, not stored in history

turn 2
systemu₁ · [resp₁ only]u₂ gen₂
turn 3
systemu₁·[resp₁]·u₂·[resp₂]u₃ gen₃

Smaller cached block — leaner, less memory of the argument. Still all cheap reads.

Both columns: system + every prior turn is a cache hit (green); only the new user line and the new generation are full price. They commit once to what each turn looks like and only ever append — so the prefix stays identical going forward. The choice is memory vs. leanness, not cheap vs. expensive.

6 · Why it pays off most with a user in the loop

Turn 1 pays a one-time write for the materials (~1.25× input). Every turn after reads them at ~0.1×, and only the new user line + new generation are full price. So per-turn cost stays roughly flat as the chat grows — and the longer the conversation, the more the one-time write is amortized.

7 · Four things that quietly break it

8 · Adding / removing a voice without throwing the cache away

Changing the cast is bust #3 — but only the naïve way. The fix is §4 and bust #2 again: freeze the system block at room creation and never touch it; let everything new ride in at the tail, after the last turn. The dialog (§5) is already a cached, append-only prefix — so the one cache-safe place for a new persona is after the last <turn>, right where the next user line goes. This is the product's live-cast feature — shipped: a room's cast is per-room, and voices can be added or retired mid-room exactly this way.

The whole dialog is re-sent every turn (it's the panel's only memory) — but as a cached prefix it re-reads nearly free. A joiner's profile slots in after that block, not into the system:

add Feynman — his sheet rides in after the last turn ✓  (invited between turns = a "joined" marker, no call)
system · Confucius · Lovelace u₁ · … · the whole dialog so far + Feynman's sheet uₙ genₙ

System + every prior turn stay green. Only Feynman's sheet is written, once. A join costs one sheet — no matter how long the conversation.

The tempting wrong move: tuck Feynman into the system block with the others — even at its very end.

add Feynman inside the system block ✗
system · Confucius · Lovelace + Feynman the whole dialog — re-read uₙ

The system sits before the dialog, so touching it — even appending to its end — moves the first-differing byte ahead of the conversation and re-reads all of it. The §3 miss, self-inflicted. (That's the catch in "just append the profile": append it to the sequence, after the last turn — not to the system block.)

Remove → "stop voicing X" at the tail, don't delete. Same rule. Deleting a sheet from the system shifts the prefix and re-reads the dialog. Instead leave the sheet where it is — dormant, still a cheap read — and append a "stop voicing X" line after the last turn.

retire Lovelace ✓  (cast: Confucius · Lovelace · Feynman)
system · Confucius · Lovelace (dormant) · Feynman the dialog so far “stop voicing Lovelace”

Lovelace's tokens stay cached but inert — you keep paying the cheap read, and avoid a re-write entirely. A retire is essentially free.

9 · Compaction — collapse the log before it tangles

Append-only (§8) is the steady-state mode — cheap, cache-friendly. But it's a log, and a log of cast changes goes wrong when the same voice churns in and out. Picture Feynman toggling all session:

append-only, after churn — the ledger
system · Confucius · Lovelace U₁·G₁ +Feynman sheet U₂·G₂ −Feynman U₃·G₃ +Feynman U₄·G₄ −Feynman U₅

Compaction is the fix: a one-time full rewrite that collapses the log into a clean snapshot of the current state — the same log→snapshot move as an LSM-tree or event-sourcing.

after compaction — one write, a clean snapshot
system · Confucius · Lovelace (cast stated) U₁·G₁ · U₂·G₂ · U₃·G₃ · U₄·G₄ — dialogue kept, toggles & dead sheets stripped U₅

The ledger is gone, the current cast is explicit again. Feynman's past lines stay (real memory) — only his sheet and the tags drop. Append-only then resumes against the fresh snapshot.

Code can compact it precisely — no model needed. The prompt is a projection of the canonical event log + the current cast; the server never parses its own prompt, it re-renders from source:

compact(room): system = build_system_blocks(room.cast) # current cast only — no dormant sheets, no toggles messages = [e for e in room.events # keep the real dialogue… if e.type in (speak, confer)] # …drop join/leave tags + dormant sheets

Exact and lossless for the dialogue — this is cast / control compaction. (Summarising the dialogue itself to save tokens is a different, lossy job that would need the model — not this.)

And code can tell when to compact — thresholds on counters the server already keeps:

needs_compaction(room): free = (cold_cache or add_queued) and cruft > 0 # a write's already happening → ride it, free forced = per_char_toggles >= K or cruft_pct > 15 # churn / dead weight too high → deliberate write return free or forced

Two triggers: opportunistic (a cold cache past TTL, or an add — the write is forced anyway, so the garbage-collect rides free) and forced (churn or dead weight crosses a line — correctness now outranks the one-time write, so you compact even on a warm cache). You can't compute the model's exact confusion point — and you don't need to: set the threshold conservative and compact a little early. One cheap write beats a mis-voiced turn.

How cruft_pct is computed — the dead-weight share of the cached prefix, tracked at no extra cost:

cruft_pct = dead_tokens / prefix_tokens dead_tokens = Σ tokens(sheet) for every char NOT in room.cast # dormant profiles + Σ tokens(tag) for every join / leave / “stop X” # the toggle ledger prefix_tokens = the cached prefix size = last turn's cache_read (+ cache_write) tokens

The denominator falls out of the usage you already log; the numerator is a running counter the server bumps on each cast change (retire → move that sheet's tokens live→dead, re-add → back, each tag → +dead). A rough count (len(text)/≈4) is fine — it only feeds a threshold. E.g. two dormant ~2k sheets + ~30 tags ≈ 4.4k dead against a ~28k prefix → ~16%, over the 15% line → compact on the next write.

Cheap palliative — make every toggle absolute. Since the server authors the tags, emit the full current cast at each change — “Cast is now: Confucius, Lovelace.” rather than “stop voicing Feynman.” The model then reads the latest roster instead of replaying the ledger, which pushes the confusion trigger far out — leaving compaction to handle mostly token weight. Append-only stays clean longer; compaction still resets it eventually.