Repository navigation
Tracking: stable prompt prefix cache across turns, conversations and providers #17102
Description
Tracking: stable prompt prefix cache across turns, conversations and providers
Provider prompt caches (vLLM/SGLang prefix caching, OpenAI, Anthropic and Bedrock breakpoints, Gemini, OpenRouter, Kimi, GLM) reuse a request only up to its first changed byte. LibreChat and @librechat/agents change bytes early in the request during a conversation, so long conversations and many users of one agent pay for the same prompt again and again (regression #16877). This issue tracks the series that fixes it. All LibreChat changes sit behind endpoints.agents.promptCache.stableContext (default false, requests unchanged).
Problem and goal
turn N: [tools ][system + date 10:41 + context ][history] [u ]
turn N+1: [tools'][system + date 10:42 + context'][history][a without thinking][u']
^ any changed byte here: everything after it is written to cache again
What changes the early bytes on dev, and what the series does instead:
on dev |
with the series |
|---|---|
| per-turn timestamp in the system prompt (#15419), dynamic tail moved before the last user message | time anchored to the conversation start; a date change arrives as an event in the history |
| context toggles, memory, project re-read into the system prompt every request | computed at Send, stored on the user message as an event; the system prompt stays the same |
tools rebuilt and reordered when MCP servers, actions or built-ins are switched mid-chat |
fixed tool set with a dispatcher; new tools arrive as events; reset only at compaction |
| tool rounds, Responses items, compaction summary restored differently from the live request | restored exactly as sent live |
| Claude's signed thinking blocks of earlier turns missing from restored history (measured by @rmesser, #17102 (comment)) | signed thinking stored and replayed for the same provider and model |
Why a series, not one PR
The prefix breaks for several independent reasons in different layers: the system prompt and context sources in LibreChat, tool binding and history restore in @librechat/agents. One PR would be a single large diff across two repos that can only be accepted or rejected whole. Split by cause, each PR is reviewable on its own and fixes a measurable part: #16369, for example, is the part that moves plain chat (G1). The SDK PRs need an agents release before the LibreChat PRs that use them, so they come first.
PRs
In merge order. LibreChat PRs are stacked: each diff includes the earlier PRs until they merge. "Fixes" names the scenario below that stops missing when the PR lands.
| # | Repo | Scope | Fixes | Depends on |
|---|---|---|---|---|
| LibreChat-AI/agents#613 | agents | Per-conversation instructions at a fixed system position; one summary placement after compaction | G1, G3 | — |
| LibreChat-AI/agents#614 | agents | call_tool dispatcher for tools added mid-conversation |
G2 | LibreChat-AI/agents#613 |
| LibreChat-AI/agents#615 | agents | Restored tool rounds and Responses items equal the live request | G2, G3 | LibreChat-AI/agents#613 |
| LibreChat-AI/agents#619 | agents | Signed thinking captured and replayed (based on LibreChat-AI/agents#371 by @upman) | thinking | LibreChat-AI/agents#615 |
| #16369 | LibreChat | Stable system prompt, date as event, the setting | G1 | — |
| #17145 | LibreChat | Structured system parts (no behaviour change) | — | #16369 |
| #17146 | LibreChat | Turn context: send-time diff stored on the user message | G1, G2 | #17145 |
| #17147 | LibreChat | Stable tools for MCP servers, actions, agent tools | G2 | #17146, LibreChat-AI/agents#614 |
| #17148 | LibreChat | Built-in capabilities as tool groups | G2 | #17147 |
| #17149 | LibreChat | Provider output items stored and replayed | G3 | #17148, LibreChat-AI/agents#613, LibreChat-AI/agents#615 |
| #17189 | LibreChat | Signed thinking stored with the message and replayed (based on #14547 by @upman) | thinking | #17149, LibreChat-AI/agents#619 |
#14547 and LibreChat-AI/agents#371 by @upman are open drafts; their commits are in #17189 and LibreChat-AI/agents#619 with his authorship. If they land first, those two PRs move on top and keep only their own changes.
Results
Each scenario isolates one cause of a prefix break, so a PR can be checked against the scenario it targets. All run through /api/agents/chat as a real user would, once per arm (dev, series with stableContext: true):
| scenario | what happens | what breaks on dev |
|---|---|---|
| G1 plain chat | 10 turns over several minutes, plus a second conversation of the same agent | the timestamp in the system prompt, every turn; the common case |
| G2 tools toggled | web search and three MCP servers switched mid-chat and called | tools and MCP instructions rebuilt on every switch |
| G3 compaction | 12 turns with large tool results, max context 12000 | summary and tool rounds restored differently from the live request |
| thinking | Claude thinking on, MCP calls, LibreChat restarted mid-conversation | earlier turns' signed thinking missing from restored history |
Uncached prompt tokens over G1-G3, dev -> series:
| provider, model | dev | series | saved |
|---|---|---|---|
| Anthropic, Claude Haiku 5.5, thinking | 284450 | 118358 | -58% |
| OpenAI Responses, gpt-5.4-nano | 266136 | 105002 | -61% |
| Kimi coding plan, kimi-for-coding | 212253 | 117067 | -45% |
| Z.ai coding plan, GLM-5.2 | 220759 | 133977 | -39% |
| self-hosted Qwen3.8-27B (vLLM behind NVIDIA Dynamo, through LiteLLM), OpenAI chat format | 320323 | 234114 | -27% |
Tools with thinking, real Anthropic API, cache share and misses, dev -> series: Haiku 5.5 80.2% (3) -> 87.7% (0), Sonnet 5.5 78.6% (3) -> 88.1% (0), Opus 5.5 80.4% (2) -> 88.3% (0); conversations 22-24% cheaper. G1 goes from 0% cached to 80-88% on every real provider; G2 misses drop to 0-2 per conversation. No run got an API error. Bedrock was tested offline only.
How it was measured: a logging proxy between LibreChat and the provider stores request bodies and usage (never headers); an analyzer reports cache share, misses and the first differing byte. Offline tests build each turn through the real createRun and provider classes for OpenAI, Azure, Anthropic, OpenRouter, Bedrock, Google, DeepSeek, xAI, Moonshot. Kit to repeat: https://github.com/Shabonasar/LibreChat/tree/tools/prompt-cache-measurements-delete_in_2027_q3/prompt-cache-measurements
Not in the series: stepwise masking of old tool outputs (lost more cache than it saved).
Activity
Shabonasar commentedon Oct 15, 2026
Status: the series is in final preparation. The PRs will be opened in the order of the plan in this issue over the next hours, starting with the three
@librechat/agentsPRs; the table in the description will show each one as planned, in progress, open or merged, together with the live cache measurements.Shabonasar commentedon Oct 15, 2026
The whole series is open, on current
dev(all branches merge cleanly):Each LibreChat PR is stacked on the previous one, so its diff on GitHub includes the earlier PRs until they merge; the PR's own changes are its non-merge commit(s). #17147 needs LibreChat-AI/agents#614, #17149 needs LibreChat-AI/agents#613 and LibreChat-AI/agents#615; without the SDK release the setting stays off with one warning.
Cached share of prompt tokens with the whole stack,
stableContexton vsdev(Qwen3.8-27B (vLLM behind NVIDIA Dynamo, through LiteLLM), OpenAI chat / Anthropic Messages format on LiteLLM endpoints): plain chat 0% -> 56.5% / 54.9%, MCP and tools toggled mid-chat 17.1% / 28.6% -> 74.1% / 73.7%, compaction 2.2% / 6.1% -> 20.1% / 30.2%. The backend caches in 1600-token blocks. Details in the issue body.rmesser commentedon Oct 16, 2026
We hit this issue in our use of LibreChat with Claude Opus 5.5, and tested the PRs to see if it resolved our use case. This is the conclusion of that testing:
The PRs in this stack don't restore the prompt cache across turns for Claude with thinking on. We measured this against the real Anthropic API (LibreChat v0.8.8, @librechat/agents 4.0.1, Claude Opus 5.5 with adaptive thinking, 1-hour prompt caching) by logging the requests between LibreChat and api.anthropic.com.
Opus 5.5 keeps earlier turns' thinking blocks in the prompt.
formatAgentMessagesdoesn't replay them: it skipsthinkparts, and the stored parts have no signature. So every turn that follows one where the model thought breaks the prefix at the start of that turn's reply. agents#615 leaves this as is on purpose; itsliveRestore.test.tssays:That comparison passes in the harness, but against the real API those set-aside blocks are where the cache breaks. We rebuilt one turn's follow-up request exactly from the live requests and sent it twice:
The read stops at the previous turn's first thinking block.
We also saw the two differences agents#615 fixes: sequential tool rounds merged into one message, and text before a tool call moved into the final answer. On turns where the model thought, fixing those alone doesn't change the result, because the request already differs at the first thinking block, before either of them.
For Claude models that keep earlier thinking, restore needs to replay the signed
thinkingandredacted_thinkingblocks from earlier turns, as #14547 / LibreChat-AI/agents#371 propose. The harness should also compare turn boundaries with reasoning included for those models. Across our deployments, these re-writes at turn boundaries account for about a quarter of model spend.Shabonasar commentedon Oct 16, 2026
@danny-avila when you have time, could you take a look at this series?
Provider prompt caches stop at the first changed byte. Today the per-turn timestamp, tools toggled mid-chat and the compaction summary rendered in two places break the prefix early. With
endpoints.agents.promptCache.stableContext: truethe cached share of prompt tokens goes, dev -> on: plain chat 0% -> 55-57%, MCP/tools toggled mid-chat 17-29% -> 74%, compaction 2-6% -> 20-30% (self-hosted vLLM through LiteLLM; the real provider APIs were not measured). The setting is off by default, and then requests are unchanged.I opened the stack without claiming it first, sorry about that. I'm happy to split it, cut it down or close the parts you don't want; #17145-#17149 are in draft until the first two are reviewed. Suggested order: LibreChat-AI/agents#613, then #16369, then up the stack.
Would you be open to reviewing it, and could this issue be assigned to me?
Shabonasar commentedon Oct 16, 2026
@rmesser thank you for testing the stack against the real API and for the exact request comparison. That is the measurement my setup could not give: my runs were on a self-hosted model through LiteLLM, without Anthropic thinking.
You're right: with models that keep earlier turns' thinking, restored history breaks the prefix at the previous turn's first thinking block, and the
liveRestorecomparison hides it by setting those blocks aside. I'm taking this on.@upman already did the core of it in #14547 / LibreChat-AI/agents#371 (signed or encrypted reasoning persisted and replayed only for the originating provider, model and API mode). Rather than writing a second version, I'll integrate that work into this series, keeping your authorship, and reconcile it with LibreChat-AI/agents#615 and #17149, which store Responses provider items on the same path. Then:
Shabonasar commentedon Oct 16, 2026
Update: the issue body now has results on real provider APIs, and the series has two new PRs.
Added:
All PRs of the series, including the two new ones, are ready for review; merge order and dependencies are in the PR table of the issue body.
Savings,
dev-> series, in uncached prompt tokens over G1-G3:@rmesser since you hit this with Claude Opus 5.5: our Opus 5.5 run (effort low, MCP tool calls, restart in the middle) goes from 2 misses to 0 with #17189 and LibreChat-AI/agents#619. A re-run on your setup would be very welcome.
With thinking and tool calls, conversations on Claude Haiku, Sonnet and Opus are 22-24% cheaper and have no cache misses. Claude Haiku 5.5 needs #17062.
Shabonasar commentedon Oct 18, 2026
Update: all PRs of the series are updated to current
dev/mainand mergeable again; only conflict adaptations, plus one small fix in LibreChat-AI/agents#619 for the new non-streaming Bedrock path (with a test). Claude Haiku 5.5 is recognized bydevsince #17201, so #17062 is no longer needed; a short re-run on plaindev+ the series with Haiku 5.5 gives the same numbers (tools 87.4% from cache, 0 misses; G2 85.3%, 0 misses).SwDevOp72 commentedon Oct 19, 2026
Sonnet 5.5 / Haiku 5.5: append-only becomes a correctness requirement, not only a cache optimization
Thanks for this series.
promptMessageTimestampsin particular is exactly what we need: the send time derived from the storedcreatedAtgives the model the current time for questions like "which meetings do I still have today?" without touching the system prompt.One aspect I could not find in the PRs: on Claude Sonnet 5.5 (and, per its docs, Haiku 5.5), thinking blocks are bound to the prefix that produced them. The API checks whether
system,toolsor any earlier message changed since a block was produced. On accounts created on or after 2026-08-31 this is enforced by default and returns a 400. With thethinking-binding-controls-2026-08-01beta andprefix_mismatch_behavior: "drop_block"(which LibreChat already sends for models wherebindsThinkingBlocksis true), the affected blocks are dropped instead. Anthropic recommends keeping conversations append-only and describesdrop_blockas a stopgap.For prefix caching, a part that moves every turn only costs the tail. For these models it costs more: every thinking block after the changed position is dropped, so the model loses its earlier reasoning on each turn, which Anthropic notes can raise cost per task.
Two places where this still seems to happen, if I read the descriptions correctly:
additional_instructions, "where the SDK moves them next to the newest user message". Whenever one of these parts is present, each turn removes it from its previous position.[D3] u3on turn 3 andu3withoutD3on turn 4. IsDrendered again at its original position on later turns, or only on its own turn?Suggestion: for models where
bindsThinkingBlocksis true andstableContextis on, treat the invariant strictly: each request is a byte-for-byte extension of the previous one up to the last assistant message, and nothing disappears from an earlier position. Per-turn parts would stay stored on their message and render at the same place on every later turn, likecontextUpdates. Logging theinput_transformationsthat the API returns with the beta header would also let operators see when blocks get dropped.This is based on the Anthropic docs and the PR descriptions, not on a measurement against the real API.
Docs: