Your model card says 128k. The machine serving it picked 4k.
I got this one wrong first, and the reason is worth more than the correction.
I assumed the effective context window was a property of the model file. Dump the GGUF header, read context_length, compare it against what the model card claims, done. That check is real, but it answers a narrower question than I thought. The file can only push your context down. The number you actually get is chosen on the machine that loads the model, from the VRAM it can see, and 4096 is what falls out when nobody said otherwise.
So a box that is too small and a quantization that was built too small produce the same number through two different code paths, and only one of them is in the file you were told to inspect.
The branch that picks the number
From ollama/ollama, server/routes.go:2576-2586 as of today:
// Set default context based on VRAM tier // Use slightly lower thresholds (47/23 GiB vs. 48/24 GiB) to account for small differences in the exact value switch { case totalVRAM >= 47*format.GibiByte: s.defaultNumCtx = 262144 case totalVRAM >= 23*format.GibiByte: s.defaultNumCtx = 32768 default: s.defaultNumCtx = 4096 } slog.Info("vram-based default context", "total_vram", format.HumanBytes2(totalVRAM), "default_num_ctx", s.defaultNumCtx) totalVRAM is summed over your devices as gpu.TotalMemory - envconfig.GpuOverhead() (:2571-2574), so the input to that switch is VRAM after overhead, not the number printed on the box. The thresholds are 47 and 23 rather than 48 and 24 for that reason, which the comment above the switch says out loud. A 24 GiB card whose overhead eats a gigabyte lands in the 4k arm.
At the other end of the same file, envconfig/config.go:230 declares the override and defaults it to zero:
ContextLength = Uint("OLLAMA_CONTEXT_LENGTH", 0) and :338 documents what zero means: Context length to use unless otherwise specified (default: 4k/32k/256k based on VRAM).
When the tier applies at all
The VRAM number is not a fallback that runs every time. server/routes.go:203-211 returns false — meaning "do not use the automatic context" — if the request carries num_ctx, or the stored model options carry num_ctx, or OLLAMA_CONTEXT_LENGTH is non-zero. Only when all three are silent does :141-143 copy the tier value into the request:
opts := api.DefaultOptions() if opts.NumCtx == 0 { opts.NumCtx = s.defaultNumCtx } That is the sentence I would underline if I could mark up someone else's repository: 4k is not a default anyone chose. It is what remains after three separate places all declined to answer.
Where the file really does cap you
One path is about the file, and it only moves the number downward. llm/server.go:109-114:
// Verify the requested context size is <= the model training size trainCtx := f.KV().ContextLength() if opts.NumCtx > int(trainCtx) && trainCtx > 0 { slog.Warn("requested context size too large for model", "num_ctx", opts.NumCtx, "n_ctx_train", trainCtx) opts.NumCtx = int(trainCtx) } f.KV().ContextLength() comes out of the GGUF metadata, and llama.cpp is where that key is defined and read: src/llama-arch.cpp:211 maps it to "%s.context_length" and src/llama-model.cpp:1405 assigns it to hparams.n_ctx_train. So gguf-dump answers exactly one question — did this quantization get built with a small training context — and it answers it well. It cannot tell you your card is too small, because the card is not in the file.
The part that has no error message
Below both of those is the code that quietly drops your early turns. server/routes.go:3560-3617:
func truncateNativeChatMessages(ctx context.Context, m *Model, r llm.LlamaServer, opts *api.Options, req llm.ChatRequest, truncate bool) ([]api.Message, error) { if !truncate || opts == nil || opts.NumCtx <= 0 || len(req.Messages) <= 1 { return req.Messages, nil } Past that guard it walks message index i from the first to the last, and for each candidate it collects every system message before i, appends req.Messages[i:], applies the chat template to that rendering, tokenizes the rendered prompt, adds 768 tokens per image, and stops at the first i whose token count fits opts.NumCtx (:3569-3604). The message at that index and everything after it are what get sent; the turns before it are what get thrown away, except that all system messages from the discarded prefix are collected and put back in front (:3610-3616).
Three consequences, in the order I find them surprising:
- It measures the rendered prompt, not your arithmetic. The count comes from applying the chat template and tokenizing the result, so every wrapper token the template adds per turn is in the number. A template that is verbose per message makes the drop happen earlier than summing message lengths would predict.
- The system message survives and the conversation does not. Instructions stay, history goes. That is why the failure reads as "it forgot what I told it two turns ago" and not as a broken schema.
- The only trace is a debug line (
:3606-3608):
if currMsgIdx > 0 { slog.Debug("truncating native chat messages which exceed context length", "truncated", currMsgIdx) } At default log settings a run that dropped four turns and a run that kept everything print the same thing: nothing.
Three commands, about a minute
Run these against the log of the process actually serving your model, not against the client:
# 1. what the server decided, and on how much VRAM grep "vram-based default context" ollama.log # 2. did the model file cap you below what was asked for grep -c "requested context size too large" ollama.log # 3. did turns get dropped (needs the server started at debug level) grep -c "truncating native chat messages" ollama.log Line 1 is logged at Info and line 2 at Warn, so both survive a default setup. Line 3 does not — it is Debug, which means the honest reading of an empty result there is I did not look, not nothing was dropped. Start the server with the log level raised before you conclude anything from it.
If line 1 shows 4096 and your TotalMemory minus overhead is under 23 GiB, then you are on 4096 no matter what the model card claims, and nothing above Debug level will tell you. Set num_ctx in the request or OLLAMA_CONTEXT_LENGTH in the environment if you want that number to be a decision instead of a coincidence. If line 1 shows 32768 or 262144 instead, my claim does not apply to your box and the thing to look at is line 2.
What I did not measure
- I never dumped a header.
huggingface.cotimes out after 8 seconds from this machine andraw.githubusercontent.comdrops the connection immediately, both re-tested today. The source above came through the GitHub contents API instead of a clone, and there is no GGUF file on this disk. - I did not run this against a GPU. So the three numbers in the switch are the branch arms as written, not a reading from a card I own. What I can defend is what the code does when it runs, not which arm your machine takes.
- Line numbers move. Earlier notes of mine had
context_lengthatsrc/llama-arch.cpp:210and then_ctx_trainassignment aroundsrc/llama-model.cpp:1401; today the first is:211and the second is:1405. If you are checking any of this a month from now, search for the identifier and not for the number I quoted.
The check I originally wanted to write — dump the header, trust the result — would have passed on a box serving 4k, because a header that was built at 32k is a perfectly correct header. That is the whole shape of it: the thing you were told to inspect is fine, and the number is coming from somewhere else.