Skip to content

System prompt section ordering breaks LLM prefix caching for local models #40256

Description

@rhclaw

Problem

buildAgentSystemPrompt() places dynamic, per-conversation content (Messaging/inline buttons, Group Chat Context, Reactions) before the large static Project Context block. This invalidates LLM prefix caching on every request where the channel context differs, causing 8-16x slower follow-up turns on local models.

Evidence

Analysis of 49 requests from a local agent (Qwen3-Coder-30B via MLX on Apple M3 Ultra):

  • 8 distinct system prompt variants across 47 requests (24,591–26,418 chars)
  • First divergence at char ~8,510 — the Messaging section's inline buttons conditional
Scenario Prompt tokens Cache rate Response time
Stable system prompt (cache hit) 15,000 99-100% 1.0-1.8s
Changed system prompt (cache miss) 15,000 10-16% 10-16s

The 4 slowest requests (10-16s) all correlate with system prompt changes.

Root cause

In buildAgentSystemPrompt() (src/agents/system-prompt.ts), the prompt is assembled in this order:

  1. Static: Identity, Tooling, Tool Call Style, Safety, CLI Reference, Skills, Memory, Self-Update, Model Aliases, Time, Workspace, Docs, Sandbox, User Identity
  2. Dynamic (line ~24348): Reply Tags, Messaging section (inline buttons conditional varies by channel), Voice
  3. Dynamic (line ~24365): Group Chat Context / extraSystemPrompt
  4. Dynamic (line ~24369): Reactions (channel-dependent guidance)
  5. Static: Reasoning Format
  6. Static: Project Context (workspace files — often the largest block)
  7. Static: Silent Replies, Heartbeats, Runtime

The dynamic sections (#2-#4) sit in the middle, causing the entire Project Context block and everything after it to cache-miss whenever the channel context changes.

The specific line causing the most variance (line 24080 in compiled output):

params.inlineButtonsEnabled
  ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data,style?}]]`..."
  : params.runtimeChannel
    ? `- Inline buttons not enabled for ${params.runtimeChannel}...`
    : ""

Suggested fix

Move the dynamic sections (Reply Tags, Messaging, Voice, Group Chat Context, Reactions) to after Runtime — i.e., to the very end of the system prompt. LLM prefix caching matches token sequences from the start, so if the first ~90% of the prompt is always identical, the cached KV state can be reused.

Alternatively, inject dynamic per-conversation context as a separate message (e.g., a user or system message after the main system prompt) so the base system prompt remains 100% stable.

Expected improvement

With a stable ~10K token system prompt prefix:

  • Cache hit rate: 84% → ~95%+
  • Follow-up turn latency: 10-16s → 1-2s consistently
  • Total prompt tokens reprocessed per session: ~800K → ~200K (75% reduction)

Environment

  • OpenClaw 2026.3.2
  • MLX-LM 0.31.0 with --prompt-cache-size 20
  • Qwen3-Coder-30B-A3B-Instruct-8bit (MoE, 3B active)
  • Apple M3 Ultra, 512GB unified memory
  • Agent served via Telegram group chat

Notes

This primarily impacts local model deployments (MLX, llama.cpp, Ollama) where prefix caching is client-managed. Cloud providers (Anthropic, OpenAI) handle caching server-side and may be less affected, though the principle still applies.

Ref: rhclaw/pip-workspace#51

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions