KV Prefix Stability Invariant¶
Status: Active (enforced by tests/unit/framework/test_kv_prefix_invariant.py)
Why¶
Provider prefix caching (OpenAI-compatible automatic caching, KV-cache tiers)
only pays off when consecutive provider calls extend each other byte-for-byte.
Production measurement (2026-10-18, zai glm-5.3 via Sandhi): cache hits were
frozen at exactly the tools+system head (6,528 tokens) while input grew
2,685 → 56,762 across 24 calls of one agentic session — ~88% of every
request's prefill paid full price, and the same un-compacted history burned
81% of a 1M-token daily gateway budget in five minutes.
Chat-completions-style HTTP APIs are stateless: the complete conversation
payload is supplied on each call. An efficient request shape is an append-only payload
within a user turn. This preserves eligibility for provider prefix reuse;
actual hits depend on backend cache availability, granularity and routing.
OpenAI-compatible request syntax does not guarantee caching or a particular price.
The invariant¶
Between consecutive provider calls of the same user turn, the canonical
message serialization must satisfy payload[N+1].startswith(payload[N]),
with a stable tool set. This is an application-level diagnostic, not a
byte-for-byte fingerprint of the entire HTTP body: the tracker excludes images
and metadata, and its tool fingerprint is order-insensitive. Live transport
qualification must also inspect provider-specific messages and tool schemas.
Divergence is sanctioned at the following boundaries; the backend determines
how much of the earlier prefix remains reusable:
| Boundary | Cause |
|---|---|
user_turn |
new genuine user message (anchor change) |
compaction_or_trim |
compaction at a turn boundary, or the rare history-count trim |
tool_set |
mid-session tool hydration / selection change |
system_refresh |
system-prompt signature change (mode/project context) |
| model switch / resume | history rehydration creates new message identities |
Mechanisms (the "how")¶
- Anchor identity: the last genuine user message (
is_user_turn
provenance; hidden runtime messages never move it). All per-turn freezes
key onid()of that message. - Frozen ledger render (
conversation/assembler.py): the<SESSION_STATE>
block re-renders only on anchor change; intra-loop the ledger keeps
live-updating internally but sent bytes are frozen. Cross-turn freshness is
preserved; tool results ride the tail verbatim either way. - Frozen turn prefix (
agent/turn_prefix_runtime.py): dynamic guidance is
composed once per user turn; mid-loop failure signals are appended as
hidden user messages (internal_prompt_kind=failure_hint) instead of
re-rendering the prefix.apply_turn_prefixtargets the genuine anchor so
hidden messages cannot steal placement. - Sticky selection (
conversation/assembler.py): the scored older-message
set and semantic-augmentation renders freeze per turn; intra-loop budget
pressure cannot drop previously-included messages. Hard-cap escape: if the
assembled view exceedsmax_context_chars, oldest-selected messages drop
first and the event is logged. - Newest-stub dedup (
agent/tool_result_deduplicator.py): on a duplicate
tool output, the newer arrival is stubbed ("see earlier message") and the
already-sent older message stays full — byte-stable across re-assemblies,
identical token savings, metadata preserved. - Boundary-gated compaction (
agent/context_manager.pymonitor +
services/turn_execution_runtime.pyinline checks): compaction defers while
mid_user_turn(activity after the anchor); the overflow trigger overrides
(context overflow beats cache stability). - Unified history cap:
DEFAULT_MAX_CONVERSATION_HISTORY = 2000
(config/conversation_settings.py) is the single source of truth for the
Settings field,MessageHistoryconstructor/serialization defaults and both
factories. A firing trim logs[history-trim] sanctioned boundary.
Observability¶
victor/agent/kv_prefix_observability.py classifies every assembled payload:
[kv-cache] call=17 prefix=<sha12> tools=<sha12> extended=true boundary=none bytes=48211
[kv-cache] call=18 prefix=<sha12> tools=<sha12> extended=false boundary=user_turn bytes=51230 divergence=6531
extended=true— this call's payload extends the previous call's bytes.divergence=<offset>— first differing byte vs the previous call; the
mutating-message locator. Healthy sessions show divergence only on
boundary lines; a repeating small offset on intra-turn lines means a
mutator regressed.VICTOR_KV_PREFIX_DEBUG=1additionally logs a per-message hash chain, making
the exact mutating message identifiable from logs alone.
Verification¶
- Invariant guard (hard assert):
tests/unit/framework/test_kv_prefix_invariant.py
drives a multi-iteration tool loop with ledger updates, pending-action
resolution, hidden-duplicate arrivals and selection pressure. - Live smoke: run an actual agent with multiple tool iterations, verify its
deliverable, and compare consecutive model-call prefixes within the same turn.
Record sanctioned boundaries separately from unexpected divergence. Verify
raw provider usage against the normalized Sandhi fields. For fresh-only
tokens_in, the input cache ratio is
cache_read_tokens / (tokens_in + cache_read_tokens); for a provider total
prompt count that already includes hits, divide cached tokens by that total.
Keep missing cache fields unknown rather than silently replacing them with zero. - Backend outcomes: record individual-call and aggregate ratios separately.
A monotonic ratio or a 0.6 target can describe a particular workload, but is
not a portable pass condition across OpenAI-compatible providers. Explicit
zero reported hits do not establish absence of executed reuse. Pricing and
weighted budget savings require their own evidence. - Reported ZAI result (six-call handback, 2026-10-18): 22,290 fresh and
84,160 cached input tokens, or 79.1% aggregate input cache hits; the final
call was 99.4% cached. This alone does not establish a 9× daily-budget
improvement or the result of replaying an earlier 813k-token workload.