Documentation navigation
Deploying a New LLM Model
This manual walks through adding a new self-hosted LLM to an inference (GPU)
cluster — e.g. dev-par02, stg-par02, prd-par02. Models are served with
vLLM via the inference-service
chart (a KServe InferenceService), weights are streamed from an Azure storage
account (and optionally pre-cached on the node), and traffic is routed through
the LLM gateway.
The worked example throughout is corti-llm-kimi-k26-nvfp4 (Kimi K2.6,
NVFP4) on Scaleway B300 nodes.
Model tiers
Self-hosted LLMs are categorised into tiers by parameter count. The tier
determines which virtual model alias a model is exposed under (e.g.
corti-s1, corti-s1-mini, corti-s1-tiny) and helps API consumers pick
the right size class.
| Tier | Total params | Active params (MoE) | Description |
|---|---|---|---|
s1-ultra |
1T+ | — | Reserved for the largest models |
s1 |
50B–999B | >5B active | Flagship tier |
s1-mini |
20B–50B | dense or >5B active | Full-capability mid-range |
s1-tiny |
any | <5B active | Lightweight MoE, cheap inference |
s1-embedding |
— | — | Non-chat, separate |
Rules:
- Active params is the primary sort key for
s1-tiny: any MoE model with <5B active parameters iss1-tiny, regardless of total parameter count. - Total params is the primary sort key for the remaining tiers:
s1-ultra: 1T+ total.s1: 50B–999B total with >5B active.s1-mini: 20B–50B total, dense or >5B active.
-instantvariants (thinking/reasoning disabled) inherit the same tier as their base model.s1-ultrais reserved for 1T+ models. GLM-5.2 (~355B) currently serves as a placeholder in thes1-ultravirtual model until enough GPUs are available to deploy both GLM-5.2 and a 1T+ model simultaneously.
Naming convention
Base configs in config/services/<release-name>/ follow the pattern:
corti-<tier>-<date>
Routing keys in the AI gateway follow the pattern:
<provider>-corti-<tier>-<date>[-instant]
The <date> is the model's initial launch date in YYYYMMDD format. The
-instant suffix denotes the same model with reasoning/thinking disabled
via chat_template_kwargs (self-hosted) or reasoning_effort (Azure).
Examples:
| Layer | Name | Description |
|---|---|---|
| Base config | corti-s1-20260707 |
GLM-5.2, flagship tier, launched 2026-07-07 |
| Routing key | scaleway-corti-s1-20260707 |
Same model on Scaleway |
| Routing key | scaleway-corti-s1-20260707-instant |
Instant variant (thinking off) |
| Routing key | nebius-kimi-k3 |
Third-party model on Nebius (no corti- prefix) |
| Routing key | azure-gpt-5.4 |
Azure OpenAI model (no corti- prefix) |
How it fits together
flowchart LR
client["client"] -->|"x-corti-model: <model.name>"| gw["LLM gateway (shared ns)"]
gw -->|HTTPRoute| isvc["InferenceService (mlservices ns)"]
isvc -->|"corti-models://"| azure[("Azure storage account<br/>cortipipelineartifacts")]
azure -.->|"pre-download Job"| cache[("node-local cache<br/>/var/models")]
isvc --> pod["vLLM predictor pod<br/>on GPU node"]
cache -.->|"bind-mount (if cached)"| pod
- Chart:
inference-serviceresolves a GPU profile (CPU/memory/GPU count + scheduling labels) fromgpu.product, renders the KServeInferenceService, aNetworkPolicy, the gatewayHTTPRoute(s), and aSecurityPolicy. - Weights:
model.source: storageAccount+model.repository: <container>/<folder>becomes acorti-models://…storage URI, pulled from thecortipipelineartifactsaccount (Internalsubscription). - Local cache (optional but recommended on big models): with
localModelCache.enabled, KServe pre-downloads the weights once to node-local/var/modelsand the predictor pod bind-mounts them instead of re-streaming from Azure on every pod start — see Local model caching. - Routing: clients select the model with the
x-corti-model(orx-ai-eg-model) header whose value ismodel.name— see the AI Gateway guide.
Prerequisites
- Access to the model on Hugging Face, the
orcaCLI installed, and anaz loginsession. Step 1 usesorca tools model upload, which drives the Hugging Face download and theazcopyupload for you. Getting the weights into theml-model-llm-basecontainer is Step 1 below. - A GPU node pool exists in the target cluster and is
Ready, with the device plugin advertisingnvidia.com/gpuon it. If the node is brand new, confirm Node-feature-discovery, the device plugin and GFD actually schedule on it — see Managing NVIDIA GPUs. - A chart GPU profile for that node's
nvidia.com/gpu.productexists ininference-servicevalues (see Adding a GPU profile if not). - A vLLM build (
runtime.version) that supports the model's architecture, quantization, and the node's GPU compute capability. - (Only if you'll use the node-local cache) the cluster's KServe
localmodelcontroller is enabled and thelocalmodelnode-agentis wired up (hostPath/var/models, workload identity for the download Jobs). This is a one-time per-cluster setup — already done ondev-par02. See Local model caching.
1. Get the weights into Azure
Use orca tools model upload to get the weights into the
cortipipelineartifacts/ml-model-llm-base container. It downloads the snapshot
from Hugging Face first (resumable, retried until the snapshot is complete),
then azcopys the files flat into ml-model-llm-base/<dest>/ and verifies
the upload — it lists the container afterwards and fails if any local file is
missing or size-mismatched.
This replaces the old upload_model.sh helper, which lived next to this
article. The equivalent was added to Orca
— use the CLI instead.
It authenticates to Azure via your az login session (azcopy AZCLI mode), so
log in first. For gated or rate-limited Hugging Face models, also authenticate to
HF (HF_TOKEN or hf auth login).
az login # or: az account set --subscription Internal
# Download from HF (retrying until complete) AND upload in one go. The
# destination folder defaults to the lowercased repo name (here:
# kimi-k2.6-nvfp4). Always --dry-run first to confirm the file list:
orca tools model upload --repo nvidia/Kimi-K2.6-NVFP4 --dry-run
orca tools model upload --repo nvidia/Kimi-K2.6-NVFP4
# Override the destination folder with --dest:
orca tools model upload --repo nvidia/Kimi-K2.6-NVFP4 --dest kimi-k2.6-nvfp4
# Or upload a snapshot already in the HF cache with --source (e.g. after a
# manual `hf download nvidia/Kimi-K2.6-NVFP4`):
orca tools model upload \
--source ~/.cache/huggingface/hub/models--nvidia--Kimi-K2.6-NVFP4 \
--dest kimi-k2.6-nvfp4
The <dest> folder (here kimi-k2.6-nvfp4) is exactly what you reference
later as model.repository: ml-model-llm-base/<dest>.
If a Hugging Face download is killed by the OOM killer on a small VM,
lower --max-workers (default 2) to reduce peak memory — downloads are
resumable and continue from where they left off.
2. Inspect the model
Read the model's own config before choosing runtime args — it tells you the
architecture, context length, and quantization. Use the az CLI (set the
Internal subscription first):
az account set --subscription Internal
az storage blob list --account-name cortipipelineartifacts \
--container-name ml-model-llm-base --prefix "<model-folder>/" \
--auth-mode login --query "[].name" -o tsv
az storage blob download --account-name cortipipelineartifacts \
--container-name ml-model-llm-base --name "<model-folder>/config.json" \
--file /tmp/config.json --auth-mode login -o none
What to pull out of config.json / hf_quant_config.json:
| Field | Drives |
|---|---|
architectures / model_type |
Whether the vLLM build supports it |
max_position_embeddings |
Ceiling for --max-model-len |
quant_algo / kv_cache_quant_algo |
--quantization (often auto), --kv-cache-dtype |
| MoE layers | Whether --enable-expert-parallel applies; sizing tensor/expert parallelism |
3. Create the service config
Lives under config/services/<release-name>/. Put reusable settings in base/
and dev-only settings in development/. The directory name must equal the
releaseName (see value precedence).
releaseName is the constraint, not model.name. Two separate rules:
- RFC 1123-safe.
releaseNamebecomes Kubernetes resource names, so it must be lowercase alphanumeric +-only — no dots, no uppercase. That's why the release iscorti-llm-kimi-k26-nvfp4(the2.6becomesk26) even though the API id keeps the dot. - Length ≤ 36 chars. KServe names predictor pods
<release>-predictor-<rs-hash>-<pod-suffix>, which adds ~27 chars, and Kubernetes caps pod names at 63 chars.
model.name is the API-facing id (the gateway header value) and has none
of these limits, so you can keep a long, dotted, descriptive model.name
(corti-llm-kimi-k2.6-nvfp4) with a short, safe releaseName.
config/services/corti-llm-kimi-k26-nvfp4/base/values.yaml:
# yaml-language-server: $schema=../../../schemas/service.schema.json
corti:
team: text-generation
repo: corticph/charts
description: |
Kimi K2.6 with NVFP4 quantization. Optimized for 4x B300 (288GB) GPUs.
Reference: https://huggingface.co/nvidia/Kimi-K2.6-NVFP4
team: text-generation
model:
type: llm
name: corti-llm-kimi-k2.6-nvfp4 # API id sent via x-corti-model (dots OK here)
source: storageAccount
format: "vllm"
runtime:
name: "corti-vllm"
args:
- "--tensor-parallel-size=4" # MUST match the GPU profile's gpu count
- "--enable-expert-parallel" # MoE model
- "--kv-cache-dtype=fp8"
- "--max-model-len=262144"
- "--max-num-seqs=40"
- "--max-num-batched-tokens=16384"
- "--enable-chunked-prefill"
- "--enable-prefix-caching"
- "--gpu-memory-utilization=0.92"
- "--trust-remote-code"
- "--reasoning-parser=kimi_k2"
- "--tool-call-parser=kimi_k2"
- "--enable-auto-tool-choice"
gpu:
product: "NVIDIA-B300-SXM-8-288G" # must equal the node's nvidia.com/gpu.product
replicas:
min: 2 # half-node profile → 2 instances pack per B300 node
max: 2
access:
enabled: true
serviceInfra:
workloadIdentity:
enabled: false
serviceAccount:
create: false
config/services/corti-llm-kimi-k26-nvfp4/development/values.yaml:
---
model:
# <container>/<folder> in the storage account
repository: ml-model-llm-base/kimi-k2.6-nvfp4
access:
employees:
enabled: true
clusters:
dev-weu:
enabled: true
Choosing gpu.product
gpu.product does double duty in the chart: it is both the profile lookup
key and the required nvidia.com/gpu.product node-affinity value. So it
must simultaneously match a key in the chart's gpu.profiles map and the
node's nvidia.com/gpu.product label — otherwise the pod never schedules (label
mismatch) or the chart fails to render (no profile). See
Adding a GPU profile.
The B300 profile is half-node (4 of 8 GPUs). Each pod takes 4 GPUs, so
two --tensor-parallel-size=4 instances pack onto one B300 node
(2 × {4 GPU, 110 cpu, 1850Gi} fills it). That's why the example runs
--tensor-parallel-size=4 with replicas.min/max: 2. gpu.product still uses
the full SKU label string (NVIDIA-B300-SXM-8-288G); the profile, not the
label, decides how the node is split. --tensor-parallel-size must equal the
profile's GPU count, or the model won't fit / won't schedule.
Runtime args worth knowing
| Arg | Note |
|---|---|
--tensor-parallel-size=N |
Required for multi-GPU. Set N = the profile's GPU count (4 on the half-node B300 profile). |
--enable-expert-parallel |
For MoE models, to shard experts across the tensor-parallel ranks. |
--max-model-len |
Start below max_position_embeddings for a stable first bring-up; raise later. |
--kv-cache-dtype=fp8 |
Match the model's kv_cache_quant_algo. |
--quantization |
Usually auto-detected from hf_quant_config.json; only set it if detection fails. |
--enable-prefix-caching |
Safe on transformer models; risky on hybrid/Mamba — leave off initially. |
--reasoning-parser / --tool-call-parser |
The parser name must be registered in the vLLM build, or it exits at startup. |
4. Create the environment app
This is what ArgoCD's ApplicationSet watches.
environments/<env>/<cluster>/mlservices/apps/<release-name>/config.json:
{
"appName": "mlservices-corti-llm-kimi-k26-nvfp4",
"releaseName": "corti-llm-kimi-k26-nvfp4",
"chart": {
"repository": "corti.azurecr.io",
"name": "inference-service",
"tag": "1.13.0"
},
"destination": {
"namespace": "mlservices"
}
}
Add an env overlay for cluster-specific runtime settings. On Talos/Scaleway
nodes vLLM needs the host driver libraries on LD_LIBRARY_PATH and a
CUDA-matched runtime version (see
vLLM hosted models).
This is also where you enable the node-local cache.
…/apps/corti-llm-kimi-k26-nvfp4/values.yaml:
---
# Scaleway/Talos B300 (Blackwell) GPU cluster overrides
runtime:
# Required for CUDA 13.x / driver 595.71.05 on Scaleway B300 nodes
version: "v0.20.1"
env:
# Host glibc path must come first to load the host CUDA driver before
# container compat libs, to avoid driver mismatch errors. Driver locations
# differ between AKS and how we configure Scaleway nodes.
- name: LD_LIBRARY_PATH
value: "/usr/local/glibc/usr/lib:/usr/local/nvidia/lib64:/usr/local/cuda/lib64:/usr/lib/x86_64-linux-gnu"
localModelCache:
enabled: true
modelSize: 500Gi # ≥ the model's on-disk size; sizes the cache PV/PVC
Local model caching
Large models take a long time to stream from Azure on every pod start, which
makes scale-ups and restarts slow and hammers the storage account. KServe's
LocalModelCache pre-downloads the weights once to node-local
/var/models (via a download Job), and the predictor pod bind-mounts them
instead of re-streaming. Both LocalModelCache and LocalModelNodeGroup are
cluster-scoped resources rendered by the chart.
Enable it per-model in the env overlay (Step 4):
localModelCache:
enabled: true
modelSize: 500Gi # LocalModelCache.spec.modelSize — set ≥ the real on-disk size
The node-local cache only works where the cluster has been wired for it
(one-time setup, already done on dev-par02): the KServe localmodel
controller enabled, the localmodelnode-agent running with hostPath
/var/models (pinned via Kyverno on Talos), workload identity on the download
Jobs so they can read corti-models://, and the corti-models:// storage
container registered with the controller. If those aren't in place, leave
localModelCache.enabled: false and the pod streams from Azure as usual.
5. Render & verify locally
Use drdep to render the merged manifests before
committing:
orca tools drdep render \
--app corti-llm-kimi-k26-nvfp4 --cluster dev-par02 --tenant mlservices
Check the rendered InferenceService matches the target node on all three
scheduling gates, plus resources and storage:
nodeSelector→gpu-type+workloadfrom the GPU profiletolerations→ the node'sworkload=gpu-<model>taintaffinity.nodeAffinity→nvidia.com/gpu.product= yourgpu.productresources→ expectedcpu/memory/nvidia.com/gpu(4 on B300)storageUri: corti-models://<container>/<folder>- if caching: a
LocalModelCache+LocalModelNodeGroupare rendered
To preview against the live cluster: orca tools drdep diff ….
6. Commit
Follow the repo commit conventions: feat/fix only,
scope = cluster dir (or development), append the Linear ticket.
feat(development): add scaleway kimi k2.6 model [PLA-5985]
7. Smoke-test once it's live
After ArgoCD syncs and the predictor pod is Running, benchmark it with
orca tools vllm-bench to confirm it actually serves and to get a throughput
baseline:
orca tools vllm-bench run \
--selector serving.kserve.io/inferenceservice=corti-llm-kimi-k26-nvfp4 \
--namespace mlservices \
--model corti-llm-kimi-k26-nvfp4
It port-forwards to the pod and reports requests, errors, and generation
tok/s over a fixed window. errors=0 and steady gen tok/s means it's
serving; then confirm gateway routing by sending x-corti-model: <model.name>
through the LLM gateway.
Adding a GPU profile (chart side)
If gpu.product has no matching key in the chart, add a profile to
inference-service values.
The profile key must equal the node's nvidia.com/gpu.product label exactly.
The B300 profile is half-node so two tensor-parallel=4 instances pack onto one 8-GPU node:
gpu:
profiles:
NVIDIA-B300-SXM-8-288G: # == node's nvidia.com/gpu.product
cpu: 110 # 2 × 110 = 220 of 224 vCPUs; ~4 left for daemonsets
memory: "1850Gi" # 2 × 1850 = 3700 of ~3779Gi allocatable; ~79Gi headroom
gpu: nvidia.com/gpu=4 # 4 of 8 GPUs per pod; pair with --tensor-parallel-size=4
labels:
workload: "gpu-b300" # must match the node taint/label
type: "b300"
Size cpu/memory to the node's allocatable (kubectl get node <n> -o jsonpath='{.status.allocatable.memory}'), not the advertised SKU spec, and —
for a packed (multi-instance-per-node) profile — divide by the number of
instances and leave headroom for daemonsets (datadog, cilium, device-plugin,
spegel, …). Update the chart unit tests and bump Chart.yaml version, then
release so config.json can pin the new tag.
See Scheduling GPU Workloads for the taint/label/affinity naming conventions.
Troubleshooting
| Symptom | Likely cause |
|---|---|
ArgoCD/Helm fail: invalid resource name |
releaseName has a dot or uppercase — must be RFC 1123 (lowercase alphanumeric + -) |
Pod created but never Running; pod name looks truncated |
releaseName too long (> ~36 chars) — pod name exceeds 63 chars |
Pod Pending, 0/N nodes … didn't match node affinity |
gpu.product ≠ node's nvidia.com/gpu.product label |
Pod Pending, Insufficient nvidia.com/gpu |
Device plugin / GFD not running on the node yet, or --tensor-parallel-size > the profile's GPU count — see Managing NVIDIA GPUs |
Pod Pending, untolerated taint |
Missing toleration for the node's workload=gpu-<model> taint (check the GPU profile's labels.workload) |
| vLLM CrashLoop at startup | Unsupported architecture/quant for the build, unknown --*-parser, --tensor-parallel-size mismatch, or OOM (lower --max-model-len / --max-num-seqs) |
Helm render fail: Invalid GPU product |
No gpu.profiles key matches gpu.product |
| Download Job fails / weights never cached | Cluster not wired for local caching — see Local model caching, or set localModelCache.enabled: false |