Why a 35 KB System Prompt Breaks on a Local Model
- Do the context-budget arithmetic before you move a prompt, and see why a 35 KB prompt that is invisible on Claude is a quarter of a small local window
- Know exactly what Ollama does when the prompt is too long — it drops the middle, keeps a few prefix tokens, and only tells the server log
- Recognise the five behavioural signals of context exhaustion in a local agent before it wastes an hour
- Restructure a monolithic prompt into single-objective units that fit, and hand state between them on disk
- Tune the runtime knobs that matter: OLLAMA_CONTEXT_LENGTH, KV-cache quantisation, flash attention, and the parallel-request multiplier
A post that hit the Hacker News front page on September 13–14, 2026 described an experience many people are about to have: a 35 KB system prompt that had been running fine on Claude Opus was pointed at a self-hosted 27B model through Ollama, and within about three minutes the agent was re-reading files it had already read, repeating identical tool calls, and rewriting finished work. Nothing was "wrong" with the model. The prompt plus the session history had simply exceeded a 65K-token window, and the runtime had started throwing parts of the conversation away.
This page is the guide the author wished existed. It is about prompt size versus context budget, which is a different problem from prompt wording across models, covered in Porting Prompts Across Models, and from which local model to pick, covered in Run Models Locally with Ollama.
The arithmetic that nobody does first
On Claude a 35 KB system prompt is roughly 9,000 tokens, sitting inside a 1,000,000-token window: under 1 %. You never think about it. On a local runtime the same prompt is the same 9,000 tokens, but the window is whatever you configured, and the default is small:
Ollama default context (current main docs) | Machine |
|---|---|
| 4K tokens | under 24 GiB VRAM |
| 32K tokens | 24–48 GiB VRAM |
| 256K tokens | 48 GiB VRAM or more |
At 4K, a 9,000-token system prompt does not fit at all. At 32K it is 28 % of the window before the user says a word. At the 65K the HN author configured it is 14 %, which sounds fine until you add what an agent session actually carries: tool definitions (a few hundred to a few thousand tokens), every file the agent reads, every tool result, and the model's own output. A coding agent that reads three medium files and runs two commands is routinely 20–30K tokens into the conversation. With a 9K fixed cost, the usable working memory is gone in a handful of turns.
The two cloud habits that make this invisible on Claude are absent locally: the window is two orders of magnitude smaller, and there is no built-in compaction. Claude Code summarises the conversation when it approaches the limit (Context Management); a raw Ollama endpoint does not. Some local agent harnesses do their own compaction, but the runtime underneath them does not.
Measure the prompt before you migrate (any OpenAI-compatible local endpoint)
# Count what the model will actually see, from Ollama's own tokenizer.
# prompt_eval_count in the response is the tokenised prompt length.
curl -s http://localhost:11434/api/chat -d '{
"model": "qwen3:32b",
"messages": [{"role":"system","content":"'"$(cat system-prompt.md | sed 's/"/\\"/g')"'"},
{"role":"user","content":"ping"}],
"stream": false,
"options": {"num_predict": 1}
}' | jq '{prompt_tokens: .prompt_eval_count, cached: .prompt_eval_cached_count}'Compare that number against the num_ctx you run with, then subtract the tool schema and a realistic transcript. If the remainder is under about half the window, the prompt is too big for the setup, and no amount of wording will fix it.
What Ollama does when the prompt does not fit
This is the mechanism that turns "slightly too long" into "mysteriously stupid", and it is documented nowhere in prose; it is in the source.
When a request's prompt is longer than the context and truncation is enabled (it is, by default, for /api/chat and /api/generate), the llama.cpp-backed runner in Ollama does the following, in llm/llama_server.go:
- Keeps the first
num_keeptokens of the prompt. The defaultnum_keepis 4, set "to avoid issues on context shifts". - Computes a target length that leaves roughly half of the remaining context free for generation.
- Discards tokens from the middle: the kept prefix stays, the tail stays, the block between them is removed.
- Logs
truncating input promptwith the before/after sizes on the server. The API response carries no flag, and a client library sees a normal completion.
The project's own test table makes the scale concrete: with a 4,096-token context and the default num_keep, an over-long prompt is cut to 2,050 tokens. Half of the window is deliberately reserved for the reply. So a 9,000-token system prompt on a 4K window arrives at the model as four tokens of its opening, then a hole, then whatever fits at the end of the conversation. The instructions are gone; the last user message survives. That is exactly the "restating objectives, repeating tool calls" behaviour the HN post describes: the model is being asked to act on a conversation whose rules were silently deleted.
Two ways out of the silent version:
- Send
"truncate": falsein the request. Ollama then returns HTTP 400 with a message that the prompt is longer than the context currently available, instead of guessing. For an agent harness this is the correct behaviour: fail loudly, then compact or restart. - Raise
num_keepto the length of your system prompt so the prefix survives a shift. This preserves the rules but still deletes conversation from the middle; it is a band-aid, not a fix.
The chat endpoint has a second, gentler layer above this: when the message list is too long for the limit, Ollama's prompt builder drops older messages first while keeping the system message and the latest turn (see server/prompt.go). You can lose intermediate tool results this way too, which is why an agent may "forget" it already read a file. Watch the server log; with OLLAMA_DEBUG=1 the trimming is visible.
:::note Not just Ollama llama.cpp's server has the same context-shift behaviour under a different flag, vLLM refuses over-long prompts by default, and LM Studio exposes the context length as a per-model setting that is easy to leave at its small default. The pattern to internalise is: know whether your runtime truncates or errors, and prefer the error. :::
The five failure signals
The author of the HN post kept a list of what context exhaustion looked like from the outside, and it matches what people report about every small-window agent. Treat any of these as "the window is full", not as "the model is bad":
- Identical consecutive tool calls. The model no longer has the previous result in view, so it asks again.
- Re-reading a file it already read. Same cause; the read result was trimmed.
- Restating the objective in its own words mid-task, sometimes with drift. The system prompt has been cut, and the model is reconstructing the task from the tail of the conversation.
- Tool-call parsing failures that appear after a run of clean calls. Once the examples and schema in the prompt are gone, small models fall back to their default formatting.
- High turn count relative to files actually changed. The ratio is the cheapest metric to track in a harness; when it spikes, stop and restart with fresh context.
If you run through an OpenAI-compatible client, log prompt_eval_count every turn. When it stops growing while the conversation keeps growing, truncation has started.
Restructure: single-objective prompts and state on disk
A 35 KB prompt is usually a whole product's worth of rules: personas, coding standards, tool etiquette, output formats, edge cases, and a long list of "never do X". On a 1M-token model that works by brute force. On a 32K model it does not, and the fix the HN author landed on is the same one that the strongest commenters argued for independently: split the prompt into single-objective units and run each in its own short session.
- Go through the 35 KB and tag every paragraph with the job it serves: planning, editing, testing, reviewing, writing the commit. Most monoliths contain five to eight distinct jobs plus a shared core (persona, repo facts, safety rules) that is under 2 KB.
- Each job gets the shared core plus only its own rules and examples. Aim for 2–4K tokens each. On Ollama, ship them as separate Modelfiles (`PARAMETER num_ctx` set explicitly, `SYSTEM` block per job) or as per-agent prompt files in your harness.
- Each unit writes a short structured summary (what it did, what remains, which files) to a file when it finishes; the next unit reads only that file plus the slice of the repo it needs. The session ends. This is the local-model equivalent of Claude Code's compaction, done by hand.
- Small models follow 'only do X' far better than 'don't do Y', and negative lists are where monolith prompts bloat. Convert `never touch tests` into `edit only files under src/`.
- Every tool result is context you pay for until it is trimmed. Prefer tools that return a slice (one function, one hunk) over tools that return a whole file; one HN commenter's `inspect_function` / `replace_function` pair is the canonical example.
- Finish the unit, write the hand-off, kill the session. Do not wait for the failure signals; by then the prompt has already been truncated and the output is untrustworthy.
A hand-off file the next single-objective unit reads first
# HANDOFF — written by unit 2 (implement), read by unit 3 (test) Objective completed: renamed getUser -> fetchUser across src/ Files changed: src/api/user.ts, src/pages/profile.tsx, src/hooks/useUser.ts Not done: tests still reference getUser (tests/user.test.ts) Constraints carried forward: no new dependencies; keep TypeScript strict Next unit should: update tests, run `npm test`, write result to HANDOFF.md
This is also the honest answer to the commenter who said a 35 KB prompt is "confusing and unfocused on any LLM". Frontier models paper over the bloat; local models expose it. Splitting by objective usually improves results on Claude too, and it is the design Claude plus local models assumes when it routes cheap steps locally.
Tune the runtime
Once the prompt is right-sized, four knobs decide how much window you can actually afford. All are from the Ollama FAQ and docs.
| Knob | What it does | Gotcha |
|---|---|---|
OLLAMA_CONTEXT_LENGTH=65536 ollama serve (or num_ctx per request, or PARAMETER num_ctx in a Modelfile) | Sets the window | Larger context costs memory. The docs say so bluntly; the KV cache grows linearly with it |
OLLAMA_KV_CACHE_TYPE=q8_0 | Quantises the KV cache; about half the memory of the default f16. q4_0 is about a quarter | Requires flash attention; small quality cost, larger with q4_0 |
OLLAMA_FLASH_ATTENTION=1 | Enables flash attention; prerequisite for KV quantisation and reduces memory for long contexts | Not every backend/model supports it; set it and check the server log |
OLLAMA_NUM_PARALLEL (default 1) | Concurrent requests per model | Required memory scales by OLLAMA_NUM_PARALLEL × OLLAMA_CONTEXT_LENGTH. Two parallel slots at 64K cost the KV memory of one at 128K |
A useful order of operations on a fixed memory budget: pick the largest model you can hold with a small window, then enable flash attention and q8_0 KV cache, then raise the context until the server log shows it spilling to CPU. Split context across GPU and CPU is where tokens per second collapse; several HN commenters reported dense 27B–32B models at FP8 with very long windows on dual-GPU setups, but on a single 128 GB unified-memory box the practical ceiling was the 65K the author used.
:::tip Prefix caching helps, but only for the prefix
Ollama reports prompt_eval_cached_count in every response. A stable system prompt at the very start of the conversation is re-used from the KV cache across requests, so its cost is paid once per session, not per turn. Anything after the first change in the conversation is recomputed. Put the fixed material first and the volatile material last, the same rule as Claude's prompt caching (Prompt Caching Economics).
:::
Migration checklist
- Tokenise the system prompt with the local tokenizer and write the number down.
- Set
num_ctxexplicitly. Never rely on the default; it depends on the machine's VRAM. - Decide truncate-or-error. For agents, send
"truncate": falseand handle the 400. - Split the prompt by objective; shared core under 2K tokens; each unit 2–4K.
- Hand off between units through a file; end the session after each unit.
- Log
prompt_eval_countper turn and alert when it plateaus. - Enable flash attention and
q8_0KV cache before raising the window. - Watch the server log for
truncating input promptduring the first week.
Check yourself
0/5Sources & further reading
- Notes on gotchas while migrating 35 KB preprompts from Opus to self-hosted Ollama — the September 2026 post that prompted this page (128 GB Ryzen AI MAX+ 395, 65K window, 27B model, OpenCode harness), and its Hacker News discussion
- Ollama docs: context length — the VRAM-tiered defaults and
OLLAMA_CONTEXT_LENGTH - Ollama FAQ —
OLLAMA_KV_CACHE_TYPE,OLLAMA_FLASH_ATTENTION,OLLAMA_NUM_PARALLELand the memory rule - Ollama source:
llm/llama_server.go— thetruncating input promptcontext-shift code and its test table;api/types.gofor thenum_keep: 4default;server/routes.gofortruncatedefaulting to true - Ollama API reference —
prompt_eval_count,prompt_eval_cached_count,options.num_ctx,keep_alive
Next
- Claude plus local models — the hybrid patterns that keep the big prompt on Claude and send the small, single-objective steps local
- A private local AI stack — the runtime, harness and model choices once the prompt fits
- Context Management in Claude Code — what compaction does for you on Claude, so you know what to rebuild by hand locally