Skip to content

feat(server): Anchored Prefix Caching for multi-turn agent workloads#492

Open
davidmroth wants to merge 20 commits into
Luce-Org:mainfrom
davidmroth:feat/tool-split-agent-cache
Open

feat(server): Anchored Prefix Caching for multi-turn agent workloads#492
davidmroth wants to merge 20 commits into
Luce-Org:mainfrom
davidmroth:feat/tool-split-agent-cache

Conversation

@davidmroth

@davidmroth davidmroth commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Why this PR

Lucebox already had prefix caching; it didn't have Anchored Prefix Caching — caching the prompt as separate anchors instead of one monolithic prefix.

The problem

A typical agent prompt is ~20K tokens: system message, tool schemas, conversation history, and the latest user query. With monolithic prefix caching, the server treats that entire sequence as a single unit. If anything early in the prompt changes — or a different call type evicts the cache slot — KV for all ~20K tokens is recomputed. VRAM stores every token the same way; the waste is compute: re-running the transformer on thousands of positions whose KV was already valid on the previous turn.

Agent workloads make this worse:

  • Tool schemas are massive, fixed, and shared across sessions and call types.
  • A single user action triggers multiple model calls with different conversation prefixes (main turn, tool continuation, title generation, memory, etc.).
  • A monolithic LRU cache thrashes: each call evicts the others instead of reusing the static front matter already paid for.
flowchart TB
  subgraph mono["Monolithic prompt"]
    direction TB
    M1["System + tools + history + query"]
  end
  mono --> ALL["Recompute KV for entire prompt"]
Loading

Anchored Prefix Caching

Anchored Prefix Caching separates the prompt into a cached anchor (system, tools, and history — restored from snapshot) and a delta tail (the variable user query or tool result — prefilled fresh each turn).

Restore the anchor. Prefill the delta.

For agent apps, the anchor is further tiered (implemented today as tool-split):

  • Tool anchor — system + tool definitions, pinned by schema fingerprint (SNAPSHOT_THIN), reusable across sessions that share a toolset.
  • Conversation anchor — growing history in thick prefix slots, with slot-depth validation so a shallow hash cannot restore into a slot committed at a deeper position.
  • Chain restoreRESTORE_CHAIN reloads tool + conversation anchors in one command, then prefills the delta.
flowchart TB
  subgraph anchor["Cached anchor"]
    direction TB
    A1["System + tools + history"]
  end
  subgraph delta["Delta prefill"]
    D1["User query / tool result"]
  end
  anchor --> REST["Restore snapshot"]
  delta --> PREF["Prefill new tokens only"]
  REST --> OUT["Combined KV → decode"]
  PREF --> OUT
Loading

VRAM still sees one linear token stream. The win is bookkeeping: which positions to reuse vs recompute.

Supporting fixes in this branch make Anchored Prefix Caching work at 131K context on discrete GPUs (right-sized CPU-RAM snapshots, legacy daemon protocol for SNAPSHOT_THIN / RESTORE_CHAIN, tool-argument coercion for Qwen templating, removal of the obsolete single-slot VRAM clamp).

Measured impact (dual RTX 3090, Qwen3.6-27B, 131K ctx, Hermes-shaped traffic)

Scenario Before (monolithic) After (anchored, warm)
~8.5K-token tool session prefill ~11 s ~0.5 s (~20×)
Same session wall clock ~44 s ~4–5 s
Agent-after-tool incremental turn ~5 s cold ~3.6 s with chain restore

Decode TPS is largely orthogonal (spec-decode / draft placement). This PR targets anchor restore + delta prefill — the path that determines whether agent chat feels instant or like a cold start every message.

Summary

  • Anchored Prefix Caching via thin tool KV snapshots (SNAPSHOT_THIN) + RESTORE_CHAIN
  • Python server_tools.py + tool_split/ orchestrator (Qwen3 adapter; plugin surface for other templates)
  • Prefix-cache slot-depth validation (Python prefix_cache.py + C++ prefix_cache.cpp)
  • Daemon: inline snap= on RESTORE_CHAIN; CPU snapshot backend for right-sized slots at 128K+
  • API telemetry: draft_accept_pct, avg_commit_per_step, per-step step_ms_* in usage.timings

Test plan

  • python3 server/scripts/test_prefix_cache_slot_depth.py (Python 3.10+)
  • python3 server/scripts/test_tool_split_adapters.py
  • ai.local benchmark: agent hot path + 3-turn tool session PASS
  • Cache cross-contamination regression (3-turn then agent) PASS after slot-depth fix
  • 128K context: snapshot commit + warm-turn chain restore without VRAM OOM

Review in cubic

davidmroth added 19 commits May 10, 2026 17:28
RESTORE_CHAIN now honors snap= inline snapshots, prefix-cache entries are
only confirmed when the daemon acks, and populated-slot tracking prevents
ghost thick restores. Expose prefill_ms/decode metrics in usage.timings
for benchmarks and the ai-platform Lucebox adapter.
Clamp conv prefix cache to one in-place slot when tool pins are enabled,
always allocate slot 0 first, and force chain-restore snap refresh on the
thick slot to avoid PrefixSnapshot OOM (which silently disabled speedups).
…fresh

When a thick snapshot slot is refreshed at a deeper boundary, shallow
hash→slot entries could restore the wrong cur_pos across conversations.
Track committed cut depth per slot, evict stale keys on confirm, and skip
lookup hits where key cut != slot depth.
Tool-split Python server (server_tools.py), thin KV snapshots, RESTORE_CHAIN
daemon support, prefix-cache slot-depth validation, and benchmark docs/tests.
Relocated from dflash/ to server/ to match the repo layout on main.
Thin tool KV snapshots (SNAPSHOT_THIN) + RESTORE_CHAIN for pinned tool
definitions, Python server_tools.py orchestration, prefix-cache slot-depth
validation (Python + C++), and daemon inline-snap on RESTORE_CHAIN.

Validated on dual RTX 3090: ~18x incremental prefill on turn 3+,
agent-after-tool ~3.6s wall-clock vs ~5s cold.
Reuse evicted thin-slot IDs within [slot_base, slot_base+pinned_slots),
release ToolSlotCache reservations when SNAPSHOT_THIN fails, and fix a
malformed apostrophe in the prefix-cache docstring. Add unit coverage.
Resolve add/add conflicts by keeping the Copilot-review fixes (bounded
thin-slot LRU, release reservation on SNAPSHOT_THIN failure, docstring).
Explain the innovation in plain language: split tools out of the
conversation path so PFlash can speed up chat without tool JSON in the way.
Qwen3.6 chat templates raise "No user query found in messages" when
tools= is set without a user turn. Always slice the full rendered
prompt at the tool/system boundary so Hermes webchat can pin tool KV
and restore conversation cache.
…ackend

The interactive/daemon test_dflash path allocated PrefixSnapshot buffers on
the CUDA target backend. At large max_ctx (128K) with the model + live KV
already resident, every snapshot allocation OOMed
(ggml_backend_alloc_ctx_tensors failed for PrefixSnapshot), silently
disabling the prefix cache. Route snapshots through
create_snapshot_backend() (CPU RAM on discrete GPUs), matching the
qwen35_backend, shard-IPC and layer-split daemons.
…cion

- server_tools.py speaks the legacy inline daemon protocol (SNAPSHOT_THIN
  with kv ranges, RESTORE_CHAIN, '[snap] inline slot=' acks). test_dflash
  in daemon mode was dispatching to run_qwen35_daemon, which implements a
  different SNAPSHOT_THIN (SSM-only) and no RESTORE_CHAIN, so tool pinning
  and conversation-cache confirms silently never fired. Add
  DFLASH_LEGACY_DAEMON=1 to keep the inline loop for daemon mode.
- _messages_for_adapter passed tool_call arguments through as JSON strings;
  Qwen templates iterate arguments with '| items' and raise 'Can only get
  item pairs from a mapping'. Coerce to dict for both dict-shaped and
  pydantic-shaped messages (mirrors _tokenize_prompt).
Thick prefix snapshots moved to the CPU snapshot backend (system RAM), so
the OOM rationale for clamping prefix_cache_slots to 1 alongside pinned
tool slots is gone. Agent stacks interleave multiple prompt families per
turn; a single thick slot thrashes on every request and defeats the
conversation cache.
Parse the daemon's per-request acceptance summary and per-step timing
block into DaemonStdoutBus timings, so /v1/chat/completions responses
report draft_accept_pct, avg_commit_per_step, and step_ms_* phase
breakdowns alongside prefill/decode timings. This made the 700ms/step
cross-GPU draft feature copy visible and is how decode regressions get
diagnosed without shell access to the daemon.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds “Anchored Prefix Caching” support aimed at multi-turn agent workloads by splitting the prompt into a reusable tool/schema anchor and a variable conversation tail, enabling fast restores (RESTORE_CHAIN) plus thin tool KV snapshots (SNAPSHOT_THIN). It also hardens prefix-cache correctness via slot-depth validation to prevent restoring into mismatched positions, and extends server-side telemetry to better observe prefill/step timing behavior.

Changes:

  • Add tool-split orchestration (adapters, registry, daemon bridge) to pin tool KV separately and optionally compress only the conversation suffix.
  • Implement prefix-cache slot-depth validation (Python + C++) to prevent stale hash→slot restores after in-place slot refreshes.
  • Update the legacy daemon/test harness to support the tool-split protocol (CPU snapshot backend, RESTORE_CHAIN inline snap= parsing).

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
server/test/test_dflash.cpp Adds legacy-daemon toggle, CPU snapshot backend for prefix snapshots, and RESTORE_CHAIN inline snap support.
server/src/server/prefix_cache.cpp Adds slot-depth consistency validation during prefix-cache lookup to evict stale entries.
server/scripts/tool_split/base.py Introduces core dataclasses/interfaces for tool-split adapters and daemon command planning.
server/scripts/tool_split/config.py Adds env/CLI configuration for enabling tool-split, profile selection, and pinned tool slots.
server/scripts/tool_split/registry.py Implements adapter registry plus user plugin discovery/loading.
server/scripts/tool_split/orchestrator.py Builds split plans (restore chain vs restore vs cold) and manages pinned tool-slot LRU.
server/scripts/tool_split/daemon_bridge.py Adds async IPC helpers to commit thin tool KV snapshots to daemon slots.
server/scripts/tool_split/qwen3.py Implements Qwen3.x prompt splitting at the tool/system boundary.
server/scripts/tool_split/laguna.py Adds a Laguna/Poolside splitter stub with marker-boundary fallback.
server/scripts/tool_split/example_user_plugin.py Provides a template for user-defined tool-split adapters via plugin directory.
server/scripts/tool_split/init.py Exposes tool-split public API and auto-registers built-in adapters.
server/scripts/tool_split/README.md Documents how to enable tool-split, profiles, plugins, and daemon flow.
server/scripts/test_tool_split_adapters.py Adds unit tests for registry, adapters, ToolSlotCache, and command formatting.
server/scripts/test_prefix_cache_slot_depth.py Adds unit tests covering stale shallow-hash rejection and key eviction on deep refresh.
server/scripts/server_tools.py Integrates tool-split into the OpenAI-compatible tool-aware server and daemon command composition.
server/scripts/prefix_cache.py Adds slot-depth validation in Python prefix-cache lookup and related correctness plumbing.
server/docs/tool-split-goal.md Adds a project goal doc describing tool-split success criteria and operational guidance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +770 to +772
if req.tool_choice is not None:
kwargs["tool_choice"] = req.tool_choice
prompt = tokenizer.apply_chat_template(msgs, **kwargs)
Comment on lines +2563 to +2571
// Optional inline-snap suffix (same as RESTORE / bare prompt):
// snap=<pos>:<slot_id>
if (const char * sp = std::strstr(line.c_str(), "snap=")) {
if (std::sscanf(sp, "snap=%d:%d", &snap_pos, &snap_slot) != 2
|| snap_slot < 0 || snap_slot >= PREFIX_CACHE_SLOTS) {
std::fprintf(stderr, "[snap] bad inline-snap arg\n");
snap_pos = -1; snap_slot = -1;
}
}
Comment on lines +1198 to +1205
// Prefix snapshots live in system RAM on discrete GPUs (CPU backend) so
// they never compete with model weights / KV cache for VRAM. Matches the
// qwen35_backend / shard-IPC / layer-split daemons.
ggml_backend_t snap_backend = dflash::common::create_snapshot_backend(target_backend);
if (!snap_backend) {
std::fprintf(stderr, "snapshot backend init failed\n");
return 1;
}

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15 issues found across 17 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread server/scripts/tool_split/example_user_plugin.py Outdated
Comment thread server/scripts/tool_split/qwen3.py Outdated
Comment thread server/scripts/prefix_cache.py
Comment thread server/scripts/tool_split/orchestrator.py Outdated
Comment thread server/scripts/server_tools.py
Comment thread server/scripts/tool_split/README.md Outdated
Comment thread server/scripts/prefix_cache.py
Comment thread server/test/test_dflash.cpp
Comment thread server/test/test_dflash.cpp Outdated
Comment thread server/scripts/server_tools.py Outdated
…d hardening

Defer tool-slot eviction until confirm(), purge stale mappings on abort,
wire abort_full_snap through server_tools, fix qwen3 boundary off-by-one,
add Anthropic stop_sequences handling, and harden config/registry/plugin paths.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 12 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/scripts/server_tools.py">

<violation number="1" location="server/scripts/server_tools.py:958">
P1: Streaming request cancellations skip the snapshot-abort path because the new failure-handling blocks only catch `Exception`. In Python 3.8+, `asyncio.CancelledError` is a `BaseException`, so a cancelled streaming request (e.g., client disconnect) bypasses the `except Exception:` cleanup, runs only the file-cleanup `finally`, and leaves `full_snap_prep`/`snap_prep` neither confirmed nor aborted. That can leak a reserved prefix-cache slot or leave in-progress snapshot state visible to later requests.

Consider catching `asyncio.CancelledError` alongside `Exception` in any block that performs snapshot cleanup, so cancellation also triggers `_finalize_request_snaps(..., success=False)`.</violation>

<violation number="2" location="server/scripts/server_tools.py:959">
P1: In the streaming generator path, `prompt_bin` is unlinked in the `finally` block before `_finalize_request_snaps` runs on success. Because `PrefixCache.confirm_full_snap` copies the file with `shutil.copy2`, it needs the path to still exist. When a full snap was prepared (`full_snap_prep is not None`) and generation succeeds, the file is already gone by the time the snap is confirmed, so the cache commit silently fails. The non-streaming path already gets this right by finalizing before cleanup. I suggest deferring the success-path unlink until after snap finalization — for example, by only unlinking in `finally` when `not snap_ok` and adding an explicit unlink after the success-path `_finalize_request_snaps`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

await bus.drain_timings()
snap_ok = True
except Exception:
await _finalize_request_snaps(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: In the streaming generator path, prompt_bin is unlinked in the finally block before _finalize_request_snaps runs on success. Because PrefixCache.confirm_full_snap copies the file with shutil.copy2, it needs the path to still exist. When a full snap was prepared (full_snap_prep is not None) and generation succeeds, the file is already gone by the time the snap is confirmed, so the cache commit silently fails. The non-streaming path already gets this right by finalizing before cleanup. I suggest deferring the success-path unlink until after snap finalization — for example, by only unlinking in finally when not snap_ok and adding an explicit unlink after the success-path _finalize_request_snaps.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/scripts/server_tools.py, line 959:

<comment>In the streaming generator path, `prompt_bin` is unlinked in the `finally` block before `_finalize_request_snaps` runs on success. Because `PrefixCache.confirm_full_snap` copies the file with `shutil.copy2`, it needs the path to still exist. When a full snap was prepared (`full_snap_prep is not None`) and generation succeeds, the file is already gone by the time the snap is confirmed, so the cache commit silently fails. The non-streaming path already gets this right by finalizing before cleanup. I suggest deferring the success-path unlink until after snap finalization — for example, by only unlinking in `finally` when `not snap_ok` and adding an explicit unlink after the success-path `_finalize_request_snaps`.</comment>

<file context>
@@ -911,13 +950,29 @@ async def chat_completions(req: ChatRequest):
+                await bus.drain_timings()
+                snap_ok = True
+            except Exception:
+                await _finalize_request_snaps(
+                    full_snap_prep=full_snap_prep,
+                    snap_prep=snap_prep,
</file context>

tokens = await asyncio.to_thread(list, _token_stream(r_pipe, gen_len))
await bus.drain_timings()
snap_ok = True
except Exception:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Streaming request cancellations skip the snapshot-abort path because the new failure-handling blocks only catch Exception. In Python 3.8+, asyncio.CancelledError is a BaseException, so a cancelled streaming request (e.g., client disconnect) bypasses the except Exception: cleanup, runs only the file-cleanup finally, and leaves full_snap_prep/snap_prep neither confirmed nor aborted. That can leak a reserved prefix-cache slot or leave in-progress snapshot state visible to later requests.

Consider catching asyncio.CancelledError alongside Exception in any block that performs snapshot cleanup, so cancellation also triggers _finalize_request_snaps(..., success=False).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/scripts/server_tools.py, line 958:

<comment>Streaming request cancellations skip the snapshot-abort path because the new failure-handling blocks only catch `Exception`. In Python 3.8+, `asyncio.CancelledError` is a `BaseException`, so a cancelled streaming request (e.g., client disconnect) bypasses the `except Exception:` cleanup, runs only the file-cleanup `finally`, and leaves `full_snap_prep`/`snap_prep` neither confirmed nor aborted. That can leak a reserved prefix-cache slot or leave in-progress snapshot state visible to later requests.

Consider catching `asyncio.CancelledError` alongside `Exception` in any block that performs snapshot cleanup, so cancellation also triggers `_finalize_request_snaps(..., success=False)`.</comment>

<file context>
@@ -911,13 +950,29 @@ async def chat_completions(req: ChatRequest):
+                tokens = await asyncio.to_thread(list, _token_stream(r_pipe, gen_len))
+                await bus.drain_timings()
+                snap_ok = True
+            except Exception:
+                await _finalize_request_snaps(
+                    full_snap_prep=full_snap_prep,
</file context>
Suggested change
except Exception:
except (Exception, asyncio.CancelledError):

@davide221

Copy link
Copy Markdown
Contributor

@davidmroth thanks for the huge contribution, we will review it soon. Meanwhile can you fix cubic P1s?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants