Running DeepSeek V4.1 Flash in production: serving, KV caching and autoscaling on AWS

We've used frontier models for quite some time, and we still rely on them heavily for every customer. They're the backbone of how we serve customers today. But they're heavily guardrailed, and access for cyber use sits behind gated verification programs. For the offensive side of security work, that's a real constraint: a model can refuse an authorized exploit step halfway through an engagement, and access can hinge on a verification program you're still waiting to clear. No company can rely on frontier models alone for that work. Meanwhile, attackers are already equipped with open-source models and much more. We've been building and testing agent swarms on self-hosted models, and DeepSeek V4.1 Flash is what we run them on. In one black-box run, a swarm found an exploitable flaw in a password reset flow at negligible cost. This is our setup.
Why open-source models, and why fine-tune them
Gated access is worth waiting for, but waiting doesn't stop the work. Open-source models fill the gap in three ways:
- No gate. Open weights run on hardware we control, under our own authorization and scoping rules, so there's nothing to wait on and no mid-engagement refusals.
- Customer data stays on our GPUs. Target responses, credentials found in scope and tool output never leave infrastructure we own.
- We can fine-tune them. A frontier model is a fixed artifact. An open model can be trained on our own validated findings, tool-call traces and exploit chains, so it learns our harness, our tool schemas and what a real finding looks like. On narrow, high-volume steps like triage, payload variation and tool calling, a tuned open model can close much of the gap with a frontier model, on capacity we already pay for.
It isn't either-or. When gated access opens up, frontier models take the hardest reasoning and open models take the volume.
The rest of this guide covers how it's built: the serving configuration, the sizing model, KV cache tiering including a cache shared across machines, routing, autoscaling and cost. All numbers are from our production fleet.
Workload profile
| Metric | Value |
|---|---|
| Mean prompt length | ~113K tokens (agent history, tool output, HTTP responses) |
| Request volume | tens of thousands per day, more than 10⁹ prompt tokens on busy days |
| Peak concurrency | 50–90 requests in flight |
| Output per request | hundreds of tokens, with reasoning |
| Access pattern | multi-turn, and each turn re-sends the full prior context |
1. Hardware
| Item | Value |
|---|---|
| Instance | AWS g7e.24xlarge |
| GPUs | 4× NVIDIA RTX PRO 6000 Blackwell, 96 GB GDDR7 each (384 GB) |
| Parallelism | TP=4, one model replica per instance |
| Host RAM used for KV offload | 256 GiB |
| Local storage | instance NVMe (/opt/dlami/nvme) |
At these prompt lengths, capacity is set by KV cache bytes, not by weights. Blackwell's native FP4 support matters twice: for the MoE weights and for a 4-bit KV cache format. With nvfp4_ds_mla, one instance holds 7,732,336 tokens of KV in GPU memory at --gpu-memory-utilization 0.92, about 68 conversations of 113K tokens resident at once.
2. Serving configuration
We use vLLM with the vLLM-Moet r23 patches, which add SM120 kernels for the MoE, sparse MLA attention and the DSpark speculative head. Here's the full container launch, with secrets and paths shown as variables:
docker run -d --name vllm --restart unless-stopped --gpus all --ipc host --network host --shm-size 48g \
--ulimit memlock=-1 --ulimit stack=67108864 --cap-add IPC_LOCK \
-v $DATA/models:/models:ro -v $DATA/cache:/root/.cache -v $DATA/deep_gemm:/root/.deep_gemm -v $DATA/kvcache:/srv/kvcache \
-e VLLM_ENGINE_READY_TIMEOUT_S=3600 -e VLLM_HTTP_TIMEOUT_KEEP_ALIVE=75 \
-e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 \
-e NCCL_P2P_DISABLE=0 -e NCCL_P2P_LEVEL=SYS -e VLLM_MOET_P2P_AR=0 \
-e VLLM_MOET_B12X_MOE_FUSED=1 -e VLLM_MOET_SPARSE_MLA_LDG=1 -e VLLM_MOET_TARGET_LM_HEAD_MXFP8=1 \
-e VLLM_MOET_PREFILL_SP=1 -e VLLM_MOET_B12X_MOE_PREFILL_FAST=1 \
-e NIXL_TELEMETRY_ENABLE=y \
$IMAGE \
--model /models --served-model-name deepseek-v4.1-flash --api-key $API_KEY --host 0.0.0.0 --port 8000 \
--tensor-parallel-size 4 --disable-custom-all-reduce \
--tokenizer-mode deepseek_v41 --reasoning-parser deepseek_v41 \
--tool-call-parser deepseek_v41 --enable-auto-tool-choice \
--kv-cache-dtype nvfp4_ds_mla --enable-prefix-caching \
--gpu-memory-utilization 0.92 --max-model-len 524288 \
--max-num-seqs 32 --max-num-batched-tokens 4096 \
--max-cudagraph-capture-size 128 --moe-backend b12x \
--speculative-config '{"method":"dspark","num_speculative_tokens":3,"draft_sample_method":"probabilistic","rejection_sample_method":"block","moe_backend":"b12x"}' \
--attention-config '{"indexer_kv_dtype":"mxfp4","indexer_tp_shard_min_rows":2048}' \
--kv-offloading-size 256 \
--kv-transfer-config "$KV_TRANSFER_CONFIG" # section 5What the important flags do:
| Flag | Effect |
|---|---|
--kv-cache-dtype nvfp4_ds_mla |
4-bit KV for DeepSeek's MLA layout, roughly 4× the token capacity of BF16 KV. |
--attention-config indexer_kv_dtype=mxfp4 |
Stores the sparse-attention indexer keys in MXFP4, freeing more memory for KV. |
--enable-prefix-caching |
Content-addressed reuse of KV blocks across requests with a shared prefix. |
--max-num-batched-tokens 4096 |
Chunked prefill. A 100K-token prompt is processed in 4K slices interleaved with decode steps, so decode latency stays bounded. |
--max-num-seqs 32 |
Scheduler cap on concurrent sequences. It's the denominator in our autoscaling metric (section 7). |
--max-cudagraph-capture-size 128 |
Captures CUDA graphs up to batch 128 (32 sequences × up to 4 tokens with speculation). |
DSpark, num_speculative_tokens=3 |
A draft head proposes 3 tokens and the target verifies them in one forward pass. Measured mean acceptance on our traffic: ~3.0 tokens per step. |
VLLM_HTTP_TIMEOUT_KEEP_ALIVE=75 |
Keeps idle HTTP connections open longer than the upstream load balancer and router pools (default 5 s). |
VLLM_ENGINE_READY_TIMEOUT_S=3600 |
Allows for first-start kernel autotuning and CUDA graph capture. |
How many draft tokens to use. We ran k=5 and k=3 side by side on live traffic, with the same mix at the same time. k=5 raised tokens per step from 3.09–3.11 to 3.26–3.39 (+8%), cut KV capacity from 7.73M to 7.17M tokens because of larger draft buffers, and verifies more tokens per step, which costs more at high batch sizes. k=3 is the better operating point for prose-heavy agent traffic. Code-heavy traffic accepts far more draft tokens, and the published vLLM-Moet runs at k=7 reach ~6 tokens per step on code.
3. Sizing: a per-step latency model

Decode is memory-bandwidth bound. Every step, every running sequence reads its whole KV history. At our context lengths, step time is close to linear in batch size:
t_step(N) ≈ 10 ms + 2.7 ms × N (N = running sequences, ~113K tokens each)
tok/s per request ≈ a / t_step(N) (a ≈ 3.0 accepted tokens per step)
That predicts ~95 tok/s at N=8 and ~57 tok/s at N=16. Measured on one instance, with ~84K-token prompts and 512 output tokens:
| N (concurrent) | Per request | Instance total |
|---|---|---|
| 1 | 282 tok/s | 285 tok/s |
| 8 | 87 tok/s | 697 tok/s |
| 12 | 67 tok/s | 805 tok/s |
| 16 | 57 tok/s | 914 tok/s |
| 24 | 45.5 tok/s | 1,099 tok/s |
| 32 | 39.5 tok/s | 1,265 tok/s |
Aggregate throughput rises with N, but per-request speed falls roughly as 1/(c + N). For agents, wall-clock time per investigation is driven by per-request speed, so we fix a target and size from it: N ≈ 8 per instance → ~90 tok/s per request. That target sets the autoscaling threshold in section 7.
Published single-stream numbers for this hardware, around 750 tok/s, use code prompts with ~6 accepted tokens per step and contexts of 65K tokens or less. Benchmark at your own prompt length and content mix.
4. KV cache tiering on one instance

Prefill for a 165K-token prompt takes ~9 s on one instance. Agent turns re-send nearly the same prefix, so the target is to compute each prefix once. vLLM's OffloadingConnector with TieringOffloadingSpec adds a primary CPU tier and ordered secondary tiers below GPU memory:
| Tier | Capacity | Role |
|---|---|---|
| GPU (vLLM prefix cache) | 7.7M tokens | hot working set |
Host RAM (--kv-offloading-size 256) |
256 GiB, a memory-mapped region in /dev/shm |
GPU evictions; fast reload |
Local NVMe (fs tier) |
instance NVMe | survives process restarts |
S3 (obj tier) |
unbounded, expires after 1 day | shared by every instance (section 5) |
Single-instance configuration:
{
"kv_connector": "OffloadingConnector",
"kv_role": "kv_both",
"kv_connector_extra_config": {
"spec_name": "TieringOffloadingSpec",
"block_size": 1024,
"secondary_tiers": [
{"type": "fs", "root_dir": "/srv/kvcache/r23-b1024",
"n_read_threads": 16, "n_write_threads": 16, "locality": "LOCAL"}
],
"offload_prompt_only": false
}
}block_sizeis the offload chunk size in tokens. It must be a multiple of the GPU block size, and it sets the granularity of every secondary-tier object.offload_prompt_only: falsealso offloads decode-phase KV, so content generated in one turn can be reused in the next.- The RAM tier is a named region in
/dev/shm. A cron job that removes regions no process maps (checked against/proc/*/maps) keeps restarts deterministic.
On our always-on instance, the prefix hit rate is ~99% of prompt tokens. Steady-state prefill is about 2,000 new tokens/s.
5. A shared KV tier across instances (S3)
With more than one instance, a conversation can land on an instance that has never seen it: router spill-over, a new replica, a Spot reclaim. Without a shared tier, that costs a full prefill. vLLM's obj secondary tier stores KV chunks in S3 through NIXL's OBJ backend, so any instance can load what another computed.
Configuration
Append to secondary_tiers:
{"type": "obj", "prefix": "r23-tp4-nvfp4-b1024", "io_threads": 64, "locality": "REMOTE",
"store_config": {
"bucket": "<kv-bucket>", "region": "us-east-2", "scheme": "https",
"endpoint_override": "https://s3.us-east-2.amazonaws.com",
"ca_bundle": "/etc/ssl/certs/ca-certificates.crt"}}- Objects are keyed
{prefix}/{hash_shard}/{chunk_hash}.bin. At startup the tier writes a connectivity probe at__nixl_probe__/connectivity_test. - With no static keys in
store_config, credentials come from the AWS SDK default chain. On EC2 that's the instance role through IMDSv2. With--network host, the container reaches IMDS at hop limit 1. io_threadssets the parallelism of the NIXL OBJ backend's S3 client.
Image requirements
The OBJ plugin, libplugin_OBJ.so in the nixl_cu13 wheel, links against NVIDIA cuObject and cuFile. Neither is in the stock image, and cuObject is packaged for CUDA 13.1 while the image runs CUDA 13.0. A minimal layer:
FROM vllm-moet-sm120:r23
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
libcuobjclient-13-1 libcufile-13-1 librdmacm1t64 && rm -rf /var/lib/apt/lists/* \
# keep the toolkit on 13.0; only cuFile/cuObject come from 13.1
&& update-alternatives --set cuda /usr/local/cuda-13.0 && update-alternatives --set cuda-13 /usr/local/cuda-13.0 \
&& L=/usr/local/cuda-13.1/targets/x86_64-linux/lib P=/usr/local/lib/python3.12/dist-packages/nvidia/cu13/lib \
# one libcufile.so.0 per process: point the pip copy at cuFile 1.16, which cuObject 13.1 requires
&& mv $P/libcufile.so.0 $P/libcufile.so.0.cu130 && ln -s $L/libcufile.so.0 $P/libcufile.so.0 \
&& mv $P/libcufile_rdma.so.1 $P/libcufile_rdma.so.1.cu130 && ln -s $L/libcufile_rdma.so.1 $P/libcufile_rdma.so.1Requirements, and why:
| Requirement | Reason |
|---|---|
| cuObject + cuFile 1.16 | libcuobjclient.so.1 calls cuFile symbols added in 1.16. The process resolves libcufile.so.0 once, and torch loads the pip copy first, so that copy has to be the 1.16 build. |
librdmacm1t64 |
NEEDED dependency of cuObject. |
ca_bundle |
The plugin bundles an AWS SDK (curl backend) built for a RHEL-style filesystem, with its CA path under /etc/pki. On Debian or Ubuntu, set it explicitly. |
NIXL_TELEMETRY_ENABLE=y |
The obj tier reads per-transfer telemetry (get_xfer_telemetry().xferDuration) after every completed transfer, and NIXL collects it only with this flag. |
You can check the backend independently of vLLM with a round trip through NIXL:
from nixl._api import nixl_agent, nixl_agent_config
a = nixl_agent("t", nixl_agent_config(backends=[]))
a.create_backend("OBJ", {"bucket": "<kv-bucket>", "endpoint_override": "https://s3.us-east-2.amazonaws.com",
"scheme": "https", "region": "us-east-2",
"ca_bundle": "/etc/ssl/certs/ca-certificates.crt"})
# register a DRAM buffer and an OBJ descriptor (key in metaInfo), then WRITE and READ:
# 8 MiB object: write ~160 ms, read ~95 ms from an instance in the same regionChunk size
block_size determines object size, and that's what matters most for S3:
block_size |
Object size | PUTs per 165K-token prompt | Cross-instance load, 165K tokens |
|---|---|---|---|
| GPU block (~128 tokens) | ~120 KB | ~1,300 | 5.3–7.2 s |
| 1024 | ~0.9 MB | ~160 | 2.1 s |
Larger chunks mean fewer requests per byte and better per-request throughput. The cost is granularity: a prefix is reusable only up to the last full chunk, at most 1,023 tokens, under 1% of a 113K prompt. The chunk layout is part of the key space, so put block_size in both the S3 prefix and the fs root_dir.
Cache key compatibility
Instances can share entries only if they produce identical block hashes and identical KV bytes:
- same weights, TP degree,
kv_cache_dtype, indexer dtype andblock_size; - same prefix-hash function. vLLM uses sha256 with a fixed seed unless
PYTHONHASHSEEDis set, so set it everywhere or nowhere.
Version the prefix whenever any of these change.
Measured results
| 165K-token conversation | Time to first token |
|---|---|
| Full prefill | 9.0 s |
| Loaded from S3 by an instance that never saw it (cross-region) | 2.1 s |
| Local GPU prefix hit | 0.4 s |
- A freshly booted replica served 33–42% of its non-GPU prompt tokens from the shared tier in its first few hundred requests.
- Average S3 promotion: 0.6–0.7 s per load. A local miss triggers an asynchronous S3 lookup of ~0.6–0.75 s.
- Storage stays bounded with an S3 lifecycle rule: expire after 1 day, and abort incomplete multipart uploads after 1 day.
Observability
The tier exports per-tier Prometheus counters. The useful ones:
vllm:kv_offload_tiering_chunk_queries_total{tier="2:obj"}
vllm:kv_offload_tiering_chunk_hits_total{tier="2:obj"}
vllm:kv_offload_tiering_read_bytes_total{tier="2:obj"} # S3 → CPU
vllm:kv_offload_tiering_write_bytes_total{tier="2:obj"} # CPU → S3
vllm:kv_offload_tiering_promotion_latency_seconds{tier="2:obj",result="success"}
vllm:external_prefix_cache_hits_total / vllm:external_prefix_cache_queries_total
6. Routing

Request path: ALB (HTTPS) → router → vLLM replicas. The router is vllm-router in prefixaware mode, which keeps a prefix tree mapping prompt prefixes to backends, so each turn goes to the replica holding its KV.
For production we use three adjustments:
- Keep the prefix tree across config reloads. Rebuilding the routing policy when the backend list changes resets placement for every conversation.
- Bound the prefix tree. It grows with distinct prefixes. We reset it every 6 hours.
- Spill over on load imbalance. If the matched backend has ≥ 8 more in-flight requests than the least-loaded one, route to the least-loaded backend. The conversation's next turns follow it there, and the shared S3 tier turns the move into a ~2 s load instead of a full prefill.
A controller next to the router runs every 15 s. It discovers replicas (the always-on instance plus every InService ASG instance whose /health returns 200), writes the router's dynamic backend config, drains replicas held by an ASG termination lifecycle hook until in-flight reaches 0, and publishes CloudWatch metrics. A replica is marked unhealthy only after 15 minutes of failed health checks.
Spot replicas run in a second region (us-east-2), connected by VPC peering with ~11 ms RTT. They reach S3 through a VPC gateway endpoint. The always-on instance in us-east-1 reaches the us-east-2 bucket over the public endpoint with an instance role. Inter-region transfer is about $0.02/GB.
7. Autoscaling

Metric, published by the controller every 15 s:
Utilization = (Σ num_requests_running + Σ num_requests_waiting) / (32 × serving_replicas) × 100
Step scaling on the Spot ASG (min 0, max 8):
| Alarm | Statistic | Window | Threshold | Action |
|---|---|---|---|---|
utilization-high |
Average, 60 s period | 3 periods | ≥ 25% (8 per replica) | +1; +2 at ≥ 40%; +3 at ≥ 60% |
utilization-low |
Average, 300 s period | 6 periods | ≤ 10% | −1; −2 at ≤ 5% |
- Hysteresis. Removing one of N replicas at ≤ 10% leaves the rest at ≤ 10% × N/(N−1) ≤ 20%, below the 25% scale-out threshold, so the fleet doesn't oscillate.
- Scale in on the average. Agent traffic is bursty within 5-minute windows, so a
Maximumstatistic rarely meets a low threshold. - Warm-up.
EstimatedInstanceWarmupis 1,800 s, so repeated alarms during boot add only the difference. - Spot settings. Capacity Rebalancing on, AZ rebalancing suspended so warm replicas aren't replaced to even out zones, and a single instance type.
- Drain on removal. The termination lifecycle hook holds the instance until in-flight requests reach 0.
Replica boot timeline, with everything served from an in-region S3 bucket:
| Phase | Time |
|---|---|
| Instance start | 3.0 min |
Weights + 9.4 GB zstd image, parallel (s5cmd, 256 workers) |
5.2 min |
| Model load | 2.2 min |
| Profiling and kernel autotune (pre-seeded kernel cache) | 3.3 min |
| CUDA graph capture | 4.7 min |
| Total | ~18 min |
Because boot takes about 18 minutes, size for sustained load. Over a typical week, the always-on instance alone served 78 of 168 hours, one or two Spot replicas covered 56 more, and the peak hour needed seven.
8. Unit costs
| Item | Rate |
|---|---|
g7e.24xlarge on-demand |
$16.57/hr |
| Same, EC2 Instance Savings Plan (1 yr, no upfront) | 10.44/hr( 8,100/month for the always-on instance) |
g7e.24xlarge Spot (us-east-2) |
7.46–10.24/hr, 8.60 average |
| Shared S3 KV tier (PUT/GET, storage, inter-region) | ~$100–140/month for the fleet |
| Router, ALB, image/weights storage | ~$150/month |
At ~8 requests per replica on our traffic, the fleet runs about $10,000–17,000 a month, depending on the per-request speed target at peak. Cost depends on provisioned capacity, not on tokens, so long contexts, retries and multi-step reasoning don't change the bill.
Checklist
- Benchmark per-request tok/s at your real prompt length, fit
t_step ≈ c + k·N, and choose a target N per replica. - Use
nvfp4_ds_mlaKV, an MXFP4 indexer and prefix caching. With long contexts, KV capacity is the constraint. - Keep
--gpu-memory-utilizationat 0.92 or below, and validate memory changes on production-shaped traffic. - Add RAM and NVMe offload tiers through
TieringOffloadingSpec, and clean up orphaned/dev/shmregions. - For more than one replica, add the
objtier: cuObject + cuFile 1.16 in the image,ca_bundle,NIXL_TELEMETRY_ENABLE=y,block_size≈ 1024, a versionedprefix, and a 1-day lifecycle rule. - Route with a prefix-aware router, with a bounded tree and load spill-over.
- Autoscale on (running + waiting) / slots, with hysteresis, average-based scale-in and drain hooks.
- Keep an owned baseline instance, and put burst capacity on Spot.
