Note 001 · Inference

What a long conversation actually costs

A context window is the thing the paid tiers sell. This one is free, and it is also finite in a way a rented endpoint never has to admit to — so here is the whole ledger: what the model will take, what the pool actually holds, and who gets what out of it.

Stack
Qwen3.8-27B · vLLM · 2 × RTX 3090
Published
Jul 2026
Last verified
16 Aug 2026
Status
In production

TL;DRThe model accepts 262,144 tokens in a single request — a quarter of a million, and what the API will take from you today. Behind it sits one shared pool of 289,661 tokens, which is what two 24 GB cards can hold live once the weights, the activations and an image model have taken their share. Because that pool is shared rather than per-user, the web app hands it out as a ladder: one 64K slice, three 32K slices, and eighteen short ones. What makes any of this affordable is not more VRAM but an fp8 KV cache and an architecture where only a quarter of the blocks pay per-token memory at all.

The two numbers people confuse

Almost every argument about context length collapses two different quantities into one word. They are worth separating before anything else, because the interesting engineering is entirely in the gap between them.

Per request
262,144The longest single prompt the engine will accept. A hard model limit, and the number the API is bound by.
Live, everyone at once
289,661The whole KV pool. Every conversation currently held open is drawing from this one arena.

A rented endpoint only ever quotes you the first. It can, because someone else owns the second and prices it into your bill. Running the machine yourself means the second number is yours to spend, and spending it is the entire subject of this note: the pool is barely larger than one maximum-length request, so a single user asking for everything would be the whole service.

What the rig actually is

Two RTX 3090s, 24 GB each, joined by NVLink and run tensor-parallel so weights and cache split across both. vLLM serves Qwen3.8-27B-AWQ-INT4 — INT4 weights, an fp8 KV cache. The table is the running configuration, not a target:

SettingValueWhat it buys
--tensor-parallel-size2Both cards act as one pool
--max-model-len262,144The ceiling for one request — 256K
Resulting KV pool289,661Tokens live across everyone at once
--kv-cache-dtypefp8Half the cache, per token
--kv-cache-memory5.25 GiBStated explicitly, not left to a fraction
--gpu-memory-utilization0.80Headroom so the image model can load
--max-num-seqs64Concurrent sequences the engine will schedule
--speculative-configMTP, 4Drafts four ahead, verifies in one pass
--enable-prefix-cachingonA follow-up turn re-reads none of its history

Read off the running engine, 16 August 2026. Prefix caching is measurably doing its job: 84.8% of 11.0 million lookups since the last restart were hits.

Why a window this size fits at all

Because most of the model never pays for it. Qwen3.8 is a hybrid: only 16 of its 64 blocks run full attention and carry a cache that grows with the conversation. The other 48 hold a recurrent state of fixed size — a 200-page chat costs them exactly what a one-line chat does. Note 003 works that arithmetic out in full.

The fp8 cache then halves what those 16 blocks do pay, and tensor-parallelism splits the result across two cards. None of the three is clever alone; together they are the difference between a context window you can advertise and one you can afford to give away.

Then an image model moved in

On 15 August 2026 Aelius gained image generation and editing, served by its own engine on the second card. It is a real feature and it was not free. The engine's cache had to shrink to make room, and the pool fell by almost half in a single afternoon:

KV POOL BEFORE AND AFTER THE IMAGE MODELTOKENS, LIVE
Before — 14 Aug 530,081
After — 15 Aug 289,661
FIG. 1 --gpu-memory-utilization dropped from 0.94 to 0.80 and --kv-cache-memory was pinned at 5.25 GiB, down from 9.61. The fraction is only there so the engine's startup free-memory check passes with the image model resident; the explicit byte figure is what actually sizes the pool, which is why it is stated rather than inferred.

Two things made that trade acceptable. The first is that the image model is barely resident at all: its diffusion weights and its text encoder live in system RAM and are streamed onto the card one graph segment at a time, with the encoder never touching the GPU. Peak VRAM for a plain generation is 2.70 GiB rather than 7.10, at a cost of about 11 seconds per image.

The second is that an edit, not a generation, is the worst case — the reference image's VAE encode peaks at 4.11 GiB, and notably does not respond to the server's own VRAM budget at all. Budgets of 2, 2.5 and 3 GiB all peak identically, so the pool had to be sized against the measurement rather than against the flag.

Order of operations vLLM starts first, then the image model. Both size themselves against free VRAM at load, so starting them the other way round lets the language model eat the image model's share and neither one is short until the first edit arrives — which is the expensive moment to find out. The image service is bound to the engine's unit for exactly this reason: a restart has to take both down and bring both back in order.

How the pool is handed out

289,661 tokens shared by everyone at once is not very many, and the shape of real demand here is lopsided: roughly 38 daily actives whose conversations are overwhelmingly short, with an occasional long one. A flat carve-up at that pool size bought exactly two concurrent users, which is the wrong answer for both groups. So the pool is a ladder — a few big slices and a lot of small ones — and sessions are promoted upward as bigger slices free:

THE LADDER · 289,661 TOKENS, CARVEDSHARE OF POOL
One long conversation 65,536 Three full ones 98,304 Eighteen short 110,592 Headroom 15,229
FIG. 2 Slots fill top-down: the first arrival gets the 64K rung and later ones step down. Slices are keyed per signed-in device rather than per account, because a phone and a laptop in two different chats genuinely are two conversations accumulating two histories. A slice is released when the chat closes, the device logs out, or it goes idle — and the idle sweep, not the tab-close beacon, is the authoritative one.

Past the ladder, a session is not refused; it is given a short-question budget and an input cap, so it can still ask brief things while the big slices are held. The cap on those is derived from what the arena has left rather than picked — an early draft hardcoded twenty short sessions, which oversubscribed the pool by about 170% and handed out budgets the cache could not honour. That failure lands as an HTTP 500 mid-answer instead of as an honest wait, which is the wrong direction to be wrong in.

What this means if you use the API None of the ladder applies to you. It exists to keep web users fair to each other; an API request is bounded only by the model's 262,144 and by what the pool has free when it arrives. That is a deliberate asymmetry, not an oversight — a long API job is one request, while a long chat is a slice held open across many.

Where the ceiling came from

None of this started here. Through July the whole service ran on one 3090 under llama.cpp, and the serving context was stuck at 64K — anything higher silently halved decode speed. That investigation is worth keeping, because the wall turned out not to be memory.

Provenance Everything from here down was measured in July 2026, on a single card under llama.cpp. It is the history of the number, not a description of the rig above, and it has not been re-run on vLLM. It stays because it is the reason the second card was bought.

The wall at 64K

Raising context past 64K produced a distinctive failure: VRAM usage went down while decode speed halved. That signature matters — it is not memory exhaustion, it is llama.cpp's auto-fit deciding the layout does not fit and evicting layer 0 to the CPU, which on this hybrid architecture also disables the fused Gated-DeltaNet kernel:

Losing that one fused kernel roughly halves generation on a model where 48 of 64 blocks are Gated-DeltaNet blocks.

The measurement trap

A load-only test lies. llama.cpp allocates KV at load, but speculative-decoding draft-verify and compute buffers grow during generation. Past ~80K they exceeded 24 GB and the display driver silently paged VRAM to host RAM — no warning, VRAM apparently fine, decode halved anyway. Every number below is therefore a decode measurement (timings.predicted_per_second), never a "model loaded" check.

The find: the margin was the wall

llama.cpp's fit pass defaults to reserving 1,024 MiB per device (--fit-target). When its conservative estimate cannot keep that margin, it evicts layer 0 rather than dip into it. At 80K the fit pass evicted — while the same configuration, once actually allowed to load, settled at 23.5 GB with 1.1 GB still free. The margin, not the memory, was the ceiling. Lowering the target changes only fit's decision threshold, not the real allocation:

Context--fit-targetResultDecode tok/sVRAM
80K1024 (default)Evicted layer 032.722.7 GB
80K256Clean52.823.5 GB
84K128Clean — chosen56.223.6 GB
86K128Evicted44.5
88K64Evicted44.6
96K256Evicted32.7

Mean of prose + code decode, 2 slots, MTP on, vision on GPU, q8_0 KV cache, one card. 84K was a hard ceiling on that configuration: no combination of micro-batch size, draft-KV quantization, or a smaller margin bought the next 2K.

Validated under real load

Test84K configResult
64,415-token promptvs. 47.9 tok/s at the old 64K47.7 tok/s
2 concurrent × 34,325 tokensthe 64K pool returned HTTP 50039.4 tok/s agg
896×896 image ingest1,041 prompt tokens, vision on GPU3.7 s

Identical deep-context speed (so 84K was not silently host-paging), plus concurrency the old pool physically rejected — 2 × 34K does not fit in 64K.

What didn't work

What the second card bought

84K on one card was a hard stop, and every fix that would have gone further cost something real — a worse draft, a smaller batch, a coarser cache. Adding a second 3090 and moving to vLLM removed the question instead of answering it: the pool is shared across both cards, the cache is fp8 rather than q8_0, and one request may now run to 262,144 tokens, roughly 3.1× what the single-card configuration reached at its absolute ceiling.

The July finding still holds, and it is why the box was worth building on at all: the limit was never the silicon. It was a default nobody had read. The current limits are different in kind — they are a budget, written down above, that anyone can check.