fix(chat): free the conversation from the KV cache, and the cursor with the context - #31
Merged
Conversation
…th the context
Two halves of the same invariant: a conversation lives both in ai->chat and in
the KV cache, and prev_len says how much of the rendered transcript is already
in that cache. Dropping one side without the other left the two disagreeing, and
neither llm_chat_free() nor llm_context_free() maintained the pairing.
llm_chat_free() reset prev_len to 0 and dropped the messages but left the cache
untouched, so the obvious way to recover from a full context -
SELECT llm_chat_free();
SELECT llm_chat_create();
- failed with the very same "Context size exceeded", the new chat having
inherited the old one's tokens. It now clears the cache. llm_chat_create() and
llm_chat_restore() both route through here and both wanted that already; restore
in particular repopulates the history and zeroes prev_len, so it plainly meant
"replay this transcript from scratch" and simply never cleared the cache to
match.
llm_context_free() left prev_len pointing at a context that no longer existed.
Freeing and recreating a context therefore appeared to work while the model
silently lost the conversation: the next turn sent only the newest message into
an empty cache, and ai->chat.messages went on claiming turns the model had never
re-read. prev_len belongs to the context, not to the history, so freeing the
context makes it zero; the next turn then re-primes the whole transcript. The
history is deliberately kept - that is what separates freeing a context from
freeing a chat, and it is what lets a context be resized mid-conversation.
Three tests, one per recovery path, all verified against the 1.0.6 build:
chat_free_clears_kv_cache "cache still holds 254 tokens after
llm_chat_free()"
context_free_replays_chat "transcript was not replayed: 93 tokens before
the resize, only 33 after the next turn"
chat_restore_compacts_context fails outright: restore left the cache full
The third is the useful one for users: save the chat, drop the oldest turns with
plain SQL, restore. Compaction falls out of the history being an ordinary table,
and no longer needs the context to be torn down and rebuilt around it - 22
messages pruned to 4 and replayed into a 50-token cache.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
…at prompt
Follow-up on review of this PR. Three findings, all confirmed against the code;
one of them turned out to be worse than reported.
1. ai_free() bypassed the reset. The earlier commit taught llm_context_free()
to clear prev_len, but ai_free(free_llm=true) called llama_free(ai->ctx)
directly, and both llm_model_free() and llm_model_load() go through it. The
reviewer's detail is the sharp part: llm_context_create_with_options() only
calls llm_context_free() when ai->ctx is non-NULL, so after a model free it
is skipped and prev_len survives against a brand-new empty cache. Measured
before: 70 tokens of transcript, 28 after the next turn - the conversation
silently gone.
Rather than add a third copy of the reset, the two teardowns now have one
definition each - ai_context_release() and ai_chat_release() - and every
caller routes through them: the two SQL functions, and ai_free(). A context
is destroyed in two places and a chat in two places; only one of each was
maintaining the invariant, which is the actual defect.
A conversation deliberately survives a model swap: prev_len is reset with the
context, so the next turn replays the transcript through the new model's
template. That also sidesteps the byte-offset mismatch between two templates,
because the offset is zero. It is released only when the connection closes.
2. Replaying a transcript submits it as one batch. llama_decode() does not
return an error when that exceeds n_batch - llama-context.cpp:1487 is
GGML_ASSERT(n_tokens_all <= cparams.n_batch), so it aborts the process. Only
the context_size key ties n_batch to n_ctx, so llm_context_create_chat with
n_ctx alone leaves n_batch at llama's default of 2048, and a single ~3000
token prompt is enough:
llama-context.cpp:1487: GGML_ASSERT(n_tokens_all <= cparams.n_batch) failed
exit 134
Pre-existing - a long first prompt reaches it with no replay involved - but
resetting prev_len makes a whole transcript into one batch, so this PR widens
it considerably. The chat path now chunks by n_batch the way llm_text_run()
already did; only the final chunk's logits are sampled from.
3. llm_chat_free() left chat.batch pointing into the token buffer it had just
freed. No live dereference is reachable, since every read is preceded by a
tokenize that rewrites it, but it is the same invariant, so ai_chat_release()
zeroes it.
A fourth, found while checking the first: llm_chat_free() was the only thing
that ever released the history, buffers, prompt and token array, and it is a SQL
function a caller may never invoke. Closing a connection leaked all of it -
29792 bytes for a two-message chat. ai_free() now releases the chat when the
connection goes away. Every existing test called llm_chat_free() explicitly,
which is why nothing caught it.
Three tests, each verified against the previous build:
chat_survives_model_reload "transcript was not replayed: 70 tokens
before the reload, only 28 after"
chat_released_on_connection_close "29792 byte(s) not released"
chat_prompt_larger_than_n_batch exit 134 - the whole test binary aborts
The review also proposed that finding 2 surfaces as "-1: invalid input batch".
It does not; it is an assert, so the process dies. Worth the correction because
it changes the severity from a failed statement to a killed connection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
…l switch A conversation now deliberately outlives llm_model_load() / llm_model_free(), and a sampler deliberately does not. Both are contract, not incidental, and neither was written down anywhere - the four entries involved said "Unloads the current model and frees associated memory", "Frees the current inference context", "Ends the current chat session", and nothing at all on the load side. Adds a Lifecycle section up front with a table of what each call releases, the recipe for starting clean across a model switch, and the three ways out of a full context - free the chat, enlarge the context, or compact the history with SQL and restore it. The four entries now say what they release and link to it. Also corrects llm_chat_restore(): it was documented as returning NULL and actually returns the number of messages restored (verified: 2, typeof integer). Its doc now also notes that it clears the KV cache, which is what makes the compaction recipe work without rebuilding the context (verified: llm_context_used() is 0 immediately after a restore). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
…on" typo
The remaining API.md errors recorded in notes/DEMO-FINDINGS.md, each re-verified
against the current build rather than taken on trust - that note was written
against 0.7.58.
embedding_type was documented as accepting BFLOAT16. It does not:
embedding_name_to_type() spells it FLOATB16, and anything unrecognised returns 0,
which surfaces as the "must be specified" error rather than as a bad-value one.
So a caller following the documentation got told they had omitted an option they
had in fact passed:
embedding_type=BFLOAT16 -> Embedding type (embedding_type) must be specified
embedding_type=FLOATB16 -> 768 bytes
The entry now carries the right spelling, calls out that it is not BFLOAT16, and
quotes the error so the misleading message is at least searchable. All five
documented names verified against a 384-dimension model: 1536, 768, 768, 384, 384
bytes - exactly 4, 2, 2, 1 and 1 bytes per element.
json_output=1 was documented as returning "a JSON object". It returns a JSON
array: json_type() reports 'array' and the text begins '[-0.0355376,...'. Also
notes that the plain BLOB is what belongs in a sqlite-vector column, since
wrapping it is the mistake the JSON form invites.
The error message itself said "funtion". Fixed - nothing asserts on the string,
and the docs now quote it, so the two agreeing matters.
Two other items from that note are already handled: llm_context_create_chat()'s
phantom context_size=4096 preset went with #29, and llm_chat_restore()'s return
type earlier in this PR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two halves of one invariant: a conversation lives both in
ai->chatand in the KV cache, andprev_lensays how much of the rendered transcript is already in that cache. Neitherllm_chat_free()norllm_context_free()maintained the pairing, so each left the two sides disagreeing.Found while answering "what can a user actually do when they hit
Context size exceeded?" after #30.llm_chat_free()didn't clear the cacheIt reset
prev_lento 0 and dropped the messages, but left the cache full. So the obvious recovery —— failed with the very same
Context size exceeded, the "new" chat having inherited the old one's tokens. Measured on 1.0.6: 254 tokens still resident afterllm_chat_free().llm_chat_create()andllm_chat_restore()both route through here and both wanted this already. Restore especially: it repopulates the history and zeroesprev_len, so it plainly meant "replay this transcript from scratch" and simply never cleared the cache to match.llm_context_free()didn't reset the cursorprev_lenwas left pointing at a context that no longer existed. Freeing and recreating therefore appeared to work while the model silently lost the conversation — the next turn sent only the newest message into an empty cache, andai->chat.messageswent on claiming turns the model had never re-read.On 1.0.6: 93 tokens before the resize, 33 after the next turn. Post-fix:
93 → 0 → 117— the transcript comes back.prev_lenbelongs to the context, not the history, so freeing the context makes it zero. The history is deliberately kept — that's what separates freeing a context from freeing a chat, and it's what makes resizing a context mid-conversation work. Noif (chat is active)guard is needed: with no chat,prev_lenis already 0 and the reset is a no-op.The three recovery paths, before and after
llm_chat_free()+llm_chat_create()Context size exceededllm_context_free()+ recreatellm_chat_restore()The third is the one users will want. Compaction falls out of the history being an ordinary SQL table:
22 messages pruned to 4 and replayed into a 50-token cache — and it no longer needs the context torn down and rebuilt around it.
Testing
One test per path. All three verified to fail against the 1.0.6 build, with the diagnostics quoted above:
make test: 48/48 pass.Version
1.0.6→1.0.7, patch.Second round: review findings, all confirmed
An independent review raised three issues. All three were real; one is more severe than reported, and chasing the first turned up a fourth.
I need to retract something from the first version of this description, which claimed
ai_free()leftchat.vocab/chat.templatedangling into a freed model. That is not a use-after-free:llm_chat_runre-fetches both fromai->modelat the top of every turn (:1933-1934) and overwrites the stored copies before anything reads them. I could not reproduce a crash, and the code says why.1.
ai_free()bypassed theprev_lenresetThe first commit taught
llm_context_free()to reset it, butai_free(free_llm=true)calledllama_free(ai->ctx)directly — and bothllm_model_free()andllm_model_load()route through it. The review's sharp detail:llm_context_create_with_options()only callsllm_context_free()whenai->ctxis non-NULL, so after a model free it is skipped entirely andprev_lensurvives against a brand-new empty cache.Measured: 70 tokens of transcript before the reload, 28 after the next turn — the conversation silently gone.
Rather than add a third copy of the reset, each teardown now has one definition —
ai_context_release()andai_chat_release()— and every caller routes through it:llm_context_free()(SQL)ai_context_release()llm_chat_free()(SQL)ai_chat_release()ai_free(free_llm)— model load/freeai_context_release()ai_free(free_ai)— connection closeai_chat_release()+ai_context_release()A context was destroyed in two places and a chat in two places, and only one of each maintained the invariant. That duplication was the actual defect.
A conversation deliberately survives a model swap —
prev_lenis reset with the context, so the next turn replays the transcript through the new model's template. That also sidesteps the byte-offset mismatch between two different templates, because the offset is zero.2. Replaying a transcript submits it as one batch — and that aborts the process
The review expected
-1: invalid input batch. It is not an error return.llama-context.cpp:1487is:so exceeding
n_batchkills the process:Worth the correction — it changes the severity from a failed statement to a killed connection.
Only the
context_sizekey tiesn_batchton_ctx, sollm_context_create_chat('n_ctx=4096')leavesn_batchat llama's default of 2048, and a single ~3000-token prompt reaches it with no replay involved — so this is pre-existing. But resettingprev_lenturns a whole transcript into one batch, which widens it considerably. The chat path now chunks byn_batchthe wayllm_text_run()already did; only the final chunk's logits are sampled from.3.
llm_chat_free()leftchat.batchdanglingPointing into the token buffer it had just freed. No live dereference is reachable — every read is preceded by a tokenize that rewrites it — but it is the same invariant, so
ai_chat_release()zeroes it.4. Found while checking #1: closing a connection leaked the whole chat
llm_chat_free()was the only thing that ever released the history, buffers, prompt and token array — and it is a SQL function a caller may simply never invoke. Closing a connection leaked all of it: 29,792 bytes for a two-message chat.ai_free()now releases the chat when the connection goes away.Every existing test called
llm_chat_free()explicitly, which is exactly why nothing caught it.Tests
Each verified against the previous build:
make test: 51/51 pass.🤖 Generated with Claude Code
https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
Third round: documentation
None of this behaviour was written down. The four entries involved read, in full:
llm_model_free()llm_context_free()llm_chat_free()llm_model_load()Since a conversation now deliberately outlives a model switch and a sampler deliberately does not, both are contract rather than incident, and shipping them undocumented is how they get reported as bugs later.
API.mdgains a Lifecycle section up front:llm_model_load()(again)llm_model_free()llm_context_free()llm_chat_free()plus the recipe for starting clean across a model switch, and the three ways out of a full context — free the chat, enlarge the context, or compact the history with SQL and restore it. The four entries above now say what they release and link to it.
Also corrects
llm_chat_restore(), documented as returningNULLwhen it actually returns the number of messages restored — verified live:restore_returns=2 typeof=integer. Its entry now also records that it clears the KV cache, which is what lets the compaction recipe work without rebuilding the context (verified:llm_context_used()is0immediately after a restore).