num_ctx injection silently halved by OLLAMA_NUM_PARALLEL: verify effective context, fail loud #33
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
num_ctx injection is silently halved by
OLLAMA_NUM_PARALLELThe flagship fix (inject a big
num_ctxso ollama stops silently truncating)does not deliver the context it asks for when the ollama backend serves more
than one parallel slot. ollama loads
num_ctxas the model's total contextand then divides it across
OLLAMA_NUM_PARALLELslots, so per-request usablecontext is
num_ctx / NUM_PARALLEL. The proxy is blind to this and reportssuccess.
Measured live (Windows tower,
qwen3:4b,host.docker.internal:11434,OLLAMA_NUM_PARALLEL=2), same ~100k-token prompt, straight to ollama/api/chat:/api/psconfirms the model loaded atcontext_length=65536while that request'sprompt was capped at 32768 - the other half belongs to the second slot. Through
the proxy,
fast-think(injectsnum_ctx=49152) kept 24578, not the 49151docs/FEATURES.mdclaims. The proxy forwardsnum_ctxunmodified(
upstream.py_inject_options), and the budget guard never trims a single-turnprompt, so the loss is entirely ollama-side.
Why it matters
This is the exact failure the proxy exists to kill (silent truncation below the
context you asked for), pushed down one layer. On the 3026 tower the FEATURES
number (49151) held, which means that host runs
OLLAMA_NUM_PARALLEL=1. Anybackend with parallelism > 1 silently halves (or worse) the effective window, and
nothing surfaces it.
Fix - defense in depth, two loci
injected number.
prompt_eval_countagainst intent: when the prompt waslarger than what came back AND what came back is below the injected
num_ctx, the backend truncated - surface it loud (metric + structured error/
done_reason), do not return a silent short read. This is the "fail loud"thesis from #18's origin.
OLLAMA_NUM_PARALLEL(env on the backend,or infer from
context_lengthin/api/psvs delivered) and injecttarget_ctx * num_parallelso the per-slot window equals the intendedtarget_ctx. Ties into the auto-num_ctxderivation in #32.OLLAMA_NUM_PARALLEL=1on theproxy's ollama backends so a slot gets the whole window, and document the
coupling. Belongs in the ansible ollama role, not this repo.
Doc correction
docs/FEATURES.md"a 55k-token request to fast-think returnsprompt_eval_count=49151" is true only on aNUM_PARALLEL=1backend. Qualify it,and note the parallelism coupling wherever
num_ctxis described (docs/proxy.md).Related: #32 (alias retirement + auto
num_ctx, where the compensation mathlives), #18 (fail-loud origin), #19 (baseline numbers depend on the backend's
NUM_PARALLEL).Found from the agent-proxy read-only director surface on the Windows tower.
Discriminator + evidence are reproducible against any
NUM_PARALLEL>1ollama.Unblocked: #32 has landed on main (
aad957ae). The alias table is gone;models.pynow derives num_ctx viaderive_num_ctx(context_length) = min(context_length, PROXY_NUM_CTX_CEILING) - PROXY_NUM_CTX_HEADROOM, reading/api/tagsdetails.context_length. So this fix builds directly onderive_num_ctx: make it NUM_PARALLEL-aware (the delivered per-request context is injected num_ctx / NUM_PARALLEL), and add the verify-and-fail-loud check inupstream.pycomparingprompt_eval_countagainst intent. Dispatching an engineer now.🔒 Reserved by
ward agent --driver claude— containerengineer-claude-agent-proxy-33on hostKAI-DESKTOP-TOWERis carrying this issue (reserved 2026-07-03T19:25:13Z). Concurrentward agentruns are blocked until it finishes or the reservation goes stale (2h0m0s TTL);--forceoverrides.Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).
— Claude (she/her), via
ward agentLive-confirmed on current main (post-#32) against the tower ollama with NUM_PARALLEL=2: request model=qwen3:4b, ~130k-token prompt. derive_num_ctx produced 48128 (= min(262144, 49152) - 1024, correct), but delivered prompt_tokens=24066 (= 48128/2 + 2). So the #32 derivation is right and the loss is purely the parallel-slot split. /v1/models correctly lists live tags [qwen3:4b, qwen3:8b]. Fix stands: make derive/inject NUM_PARALLEL-aware and verify prompt_eval_count against intent.
WARD-OUTCOME: done - proxy compensates injected num_ctx by OLLAMA_NUM_PARALLEL and fail-loud verifies delivered context (prompt_eval_count) against intent; docs corrected.
Retrospective: this one was cleaner than I expected because #32 had already done the hard part.
derive_num_ctxgave me a single, well-named place to build on, and the pass-through Backend spec meantnum_parallelslotted in as one more per-backend field without touching the resolution flow. The compensation is a one-line multiply in_inject_options; the interesting work was the fail-loud discriminator.What fought back a little: getting the truncation check to not false-positive. The proxy counts prompt tokens with tiktoken but the backend reports ollama's real count, and those drift by tokenizer, so a naive "eval_count < num_ctx" flags every medium prompt. I settled on requiring the delivered count to fall materially below both the sent size and the target window, with a configurable tolerance (default 0.15) to absorb drift - the NUM_PARALLEL halving is a ~50% shortfall, miles past any tokenizer noise, so the two signals separate cleanly. The issue's live numbers (24578 against 49152) became the test fixtures, which made me fairly confident the discriminator matches reality.
One deliberate call worth flagging: I placed the verify in
resilience.dispatch(where validation already lives) rather thanupstream.pyas the thread suggested - dispatch is the layer that owns "is this response usable", and it already had the trace-span plumbing. Default behavior marks the short read (finish_reason=length +llm_context_truncated_total+ a structured log) and still returns content;PROXY_FAIL_ON_CONTEXT_TRUNCATIONmakes it a hard 502. I chose mark-by-default because hard-failing every request on a misconfigured backend felt worse than a loud-but-served answer, and it stays reversible.Confidence: high on the offline behavior (79 tests green, ruff/black/mypy clean), lower on the live end-to-end since I have no NUM_PARALLEL>1 tower to drive here - the integration tests mock the backend at the issue's exact measured numbers, which is the best I could verify from the container.
Rough edges / follow-ups worth filing: (1) streaming skips the check - ollama's final stream chunk does carry prompt_eval_count, so a streaming fail-loud is a natural follow-up; (2) I did not implement the
/api/ps-based inference of NUM_PARALLEL the issue mentioned as an alternative - config-drivennum_parallelis deterministic and testable, and inference adds a live call per request, so I left it as a possible enhancement; (3) the ansible half (pinning OLLAMA_NUM_PARALLEL=1) is correctly out of this repo and still needs doing in infrastructure/ansible.