02 · Offline Batch Inference
The LLM class is the shortest path from installed to generating. It's also the honest way to
measure throughput, because there's no HTTP, no network, and no client library between you and the
engine — which is why every benchmark in Stages 0–2 that didn't need a server used it.
It has two behaviours that differ from the server in ways that will silently degrade your output if you don't know about them. Both are in this page, and both are the reason it isn't simply "the same thing without the port number."
The Problem
- The same prompt gives worse answers through
LLMthan throughvllm serve, on the same model, with the same sampling parameters. Nothing in the API suggests why. - You set no sampling parameters and got outputs you didn't expect — not vLLM's documented defaults, and you can't find where the values came from.
- Your instruct-tuned model rambles, ignores instructions, or answers a different question. It worked fine in the chat UI you tested it in.
- Your evaluation results don't match production, despite using the same model and the same prompts.
- You benchmarked with
LLMand the server was slower, and you're not sure whether that's real.
The first four are the same two causes, and they're specific enough to name now: generate() does
not apply the chat template, and your sampling defaults are probably the model author's, not
vLLM's.
The Idea
The server is a restaurant. The LLM class is the kitchen.
Both cook the same food on the same equipment. But the restaurant also takes your order in a language you speak, translates it into the kitchen's shorthand, plates the result and carries it out. Walk into the kitchen and hand the chef a scribbled note, and you'll get exactly what you asked for — including the parts you didn't know you were supposed to ask for.
That's the trade, and it cuts both ways:
- You get direct access. No HTTP, no serialisation, no client library. For measuring engine behaviour, that's exactly right — you're timing the engine, not the network.
- You inherit the responsibilities. Formatting the prompt the way the model expects is now your job, because nothing is doing it on your behalf.
The mistake isn't using the kitchen. It's assuming the kitchen will plate for you.
Under the Hood
generate() does not apply the chat template
This is the single most consequential difference, and vLLM documents it plainly: llm.generate does
not automatically apply the model's chat template.
From Lifecycle of a Request, the chat template is what
turns a list of messages into the string the model was actually trained on — role markers, special
tokens, and often a default system prompt. /v1/chat/completions applies it. generate() does not.
So this:
llm.generate("Explain paged memory.") # ← the model sees exactly this, raw
sends a bare string to a model that was fine-tuned to expect:
<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>
<|im_start|>user
Explain paged memory.<|im_end|>
<|im_start|>assistant
The model still produces text — it's a language model, it will continue anything. But it's operating outside the format it was tuned for, and the result is the "rambles, ignores instructions" symptom. Nothing errors. The output is just quietly worse.
Two correct approaches:
# Option A — llm.chat() applies the template for you
outputs = llm.chat(
[[{"role": "user", "content": "Explain paged memory."}]],
sampling_params,
)
# Option B — apply it yourself, then generate
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(MODEL)
text = tok.apply_chat_template(
[{"role": "user", "content": "Explain paged memory."}],
tokenize=False, add_generation_prompt=True,
)
outputs = llm.generate(text, sampling_params)
When is raw generate() right? When you genuinely want completion rather than instruction
following — base models, perplexity evaluation, text continuation, or measuring engine throughput
where the content is irrelevant. That last case covers most of this article's benchmarks, which is
why they use it.
Your sampling defaults are not vLLM's defaults
The second surprise, and it's documented but easy to miss:
By default, vLLM applies
generation_config.jsonfrom the Hugging Face model repository if it exists — using sampling parameters recommended by the model's creator rather than vLLM's own defaults.
So when you write:
llm = LLM(model="Qwen/Qwen2.5-0.5B-Instruct")
outputs = llm.generate(prompts) # no SamplingParams at all
the temperature, top_p and top_k in effect come from the model author, not from vLLM and not
from any table in this article's Sampling Parameters
page. Different models ship different recommendations, so "the defaults" are not a fixed thing across
models.
This is usually good — the model's creator knows what it was tuned for — but it has consequences:
- Your "default" behaviour changes when you change model, with no change to your code.
- Reproducing someone's results requires knowing which config was in effect, not just which model.
- An unpinned model revision can change your sampling parameters, because
generation_config.jsonlives in the repo.
To opt out and get vLLM's own defaults:
llm = LLM(model=MODEL, generation_config="vllm") # offline
vllm serve MODEL --generation-config vllm # online
Set SamplingParams explicitly for anything you'll compare against anything else. Then the
question doesn't arise.
What generate() returns
outputs = llm.generate(prompts, sampling_params) # list[RequestOutput], one per prompt
for out in outputs:
out.prompt # the prompt string as submitted
out.outputs # list[CompletionOutput] — length n from SamplingParams
out.outputs[0].text # the generated text
out.outputs[0].token_ids # the generated token IDs
Two things worth noting. outputs is a list of RequestOutput, one per prompt — and out.outputs
is a second list, one entry per sample when n > 1
(Sampling Parameters). The double nesting catches
everyone once.
And you pass a list of prompts, not one. That's not a convenience wrapper around a loop: all of
them go into the engine's waiting queue together, and the scheduler batches them exactly as it would
batch concurrent HTTP requests. Everything from
Continuous Batching applies unchanged — which is why
llm.generate(prompts) is dramatically faster than looping over llm.generate(p).
Why offline is the honest way to benchmark the engine
For measuring engine behaviour specifically, offline removes confounders that online adds:
| Removed | Why it matters |
|---|---|
| HTTP, JSON serialisation, sockets | You're timing the engine, not your network stack |
| Client-side concurrency limits | The openai client and your thread pool have their own ceilings |
| API server CPU contention | From Lifecycle, P0 can bottleneck before the GPU does |
The trade-off is that it also removes things you do want to measure for a real deployment — queueing behaviour under arrival patterns, streaming latency, per-request TTFT under contention. So: offline for engine capacity, online for user-facing latency. Using the wrong one is how benchmarks end up unrepresentative.
Try It
Hardware: Colab T4 or any GPU with compute capability ≥ 7.5.
Experiment 1 — see the chat template difference
This is the whole page in twenty lines.
# template_matters.py — same question, two paths, different quality.
from vllm import LLM, SamplingParams
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
llm = LLM(model=MODEL, max_model_len=2048)
params = SamplingParams(temperature=0.0, max_tokens=120) # greedy, so differences are structural
question = "In one sentence, what is a KV cache?"
# A: raw generate — NO chat template applied
raw = llm.generate([question], params)[0].outputs[0].text
# B: llm.chat — template applied for you
chat = llm.chat([[{"role": "user", "content": question}]], params)[0].outputs[0].text
print("=== generate() — no template ===")
print(raw)
print("\n=== chat() — template applied ===")
print(chat)
What you should observe: the chat() output answers the question in the register you expect. The
generate() output is more likely to continue the text, restate it, produce a list of similar
questions, or drift — because the model is being asked to complete a string rather than respond to a
turn.
Now change one thing: print the rendered template alongside, using the tokeniser snippet from Lifecycle of a Request. The difference between what you sent and what the model saw is the entire explanation.
| Observation | What it proves |
|---|---|
chat() output is better-formed at temperature=0 |
The difference is structural, not sampling noise |
generate() output continues rather than answers |
The model is outside its tuned format |
| Neither errors | This failure is silent — nothing will warn you |
Experiment 2 — whose defaults are you using?
# whose_defaults.py — the model author's, or vLLM's?
from vllm import LLM, SamplingParams
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
prompt = "Write one sentence about memory."
a = LLM(model=MODEL, max_model_len=2048) # model's config
b = LLM(model=MODEL, max_model_len=2048, generation_config="vllm") # vLLM's own defaults
for label, llm in (("model's generation_config", a), ("vLLM defaults", b)):
outs = {llm.generate([prompt])[0].outputs[0].text for _ in range(5)}
print(f"{label:<28} {len(outs)} distinct outputs from 5 runs")
What you should observe: the two configurations behave differently — most visibly in output
variety, since they're very likely running at different temperatures. If they're identical, this
model ships no generation_config.json, which is itself worth knowing.
Then look at the file directly:
curl -s https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct/raw/main/generation_config.json
Whatever sampling values are in there are what you get when you pass no SamplingParams. That is
the actual default for your deployment — not anything published in a guide.
Experiment 3 — batching is automatic, and it's the whole point
# batch_vs_loop.py — one call with N prompts, versus N calls with one prompt.
import time
from vllm import LLM, SamplingParams
llm = LLM(model="Qwen/Qwen2.5-0.5B-Instruct", max_model_len=2048)
params = SamplingParams(temperature=0.0, max_tokens=64)
prompts = [f"Write one sentence about the number {i}." for i in range(32)]
t0 = time.perf_counter(); llm.generate(prompts, params); batched = time.perf_counter() - t0
t0 = time.perf_counter()
for p in prompts: llm.generate([p], params)
looped = time.perf_counter() - t0
print(f"one call, 32 prompts : {batched:6.2f}s")
print(f"32 calls, 1 prompt : {looped:6.2f}s ({looped/batched:.1f}x slower)")
What you should observe: the loop is several times slower, for identical work. Passing a list puts every prompt in the waiting queue at once so the scheduler can batch them; looping serialises them at concurrency 1. This is Stage 0's opening argument reproduced in four lines, and it's the most common way people accidentally benchmark batch size 1.
Dial It In
| Knob | What it does | Guidance |
|---|---|---|
generation_config="vllm" |
Ignores the model's generation_config.json |
Use for reproducibility and cross-model comparison |
SamplingParams(...) explicitly |
Overrides both | Always, for anything you'll compare |
llm.chat() vs llm.generate() |
Template applied or not | chat() for instruct models; generate() for base models and throughput measurement |
max_model_len |
Per-sequence cap, same as the server | Your measured p99 — it divides your concurrency (Stage 0) |
gpu_memory_utilization |
Size of the block pool | 0.9 default; lower only when sharing the GPU |
VLLM_USE_MODELSCOPE=True |
Fetch models from ModelScope rather than the Hub | Where Hub access is restricted |
--attention-backend |
Force a specific attention kernel | Diagnosing performance, or when the auto-selected backend misbehaves |
The LLM constructor takes the same engine arguments as vllm serve. Everything you learned in
Stages 0–2 about max_model_len, gpu_memory_utilization, enable_prefix_caching, quantisation and
speculative decoding applies here identically — same engine, different entry point.
Where It Bites You
Using generate() with an instruct model and no template. The highest-frequency mistake on this
page. Output quality drops, nothing errors, and the cause is invisible unless you know to look. Use
llm.chat(), or apply the template yourself.
Assuming your sampling defaults are vLLM's. They're the model author's, from
generation_config.json. Change model and your defaults change silently; leave the revision unpinned
and they can change under you.
Looping over prompts instead of passing a list. Serialises everything to concurrency 1 and throws away the entire benefit of the engine. If your offline job feels slow, check this first.
Comparing offline LLM numbers with online server numbers. They measure different things.
Offline excludes HTTP, serialisation and API-server CPU; online includes queueing and streaming. Both
are valid; neither is a substitute.
Forgetting the double nesting. outputs[i].outputs[0].text — the outer list is prompts, the
inner is samples. outputs[i].text doesn't exist and the error isn't especially helpful.
Creating a new LLM per batch. Every instantiation reloads weights, re-profiles memory and
re-captures CUDA graphs — tens of seconds to minutes. Create one and reuse it. In a script that
processes files in a loop, this is an easy accidental order of magnitude.
Expecting generate() to stream. It returns when everything is finished. For token-by-token
output you want the server, or the async engine —
Streaming & Client Patterns.
In Production
Offline is for jobs, not for users. Evaluation suites, bulk classification, synthetic data generation, nightly re-embedding — anything with a list of inputs and no one waiting. It's simpler than a server: no networking, no timeouts, no cancellation, no availability requirement.
Pin generation_config behaviour explicitly in evaluation harnesses. An eval that inherits the
model's recommended sampling parameters is measuring the model and its author's recommendations
together, which makes cross-model comparison unfair. Either set SamplingParams explicitly or use
generation_config="vllm", and record which you chose alongside the results.
Use it to size your server before you build one. An offline run tells you the engine's ceiling on your hardware with your model — the GPU-blocks number, the achievable throughput, whether a quantised variant fits. That's the input to the capacity plan, and it's much cheaper to obtain than a load test.
What changes at 10× traffic. Nothing here — offline batch jobs scale by running more of them, or larger ones, and they have no latency SLO to violate. The relevant scaling question is whether your batch job and your online server are competing for the same GPUs, which is a scheduling problem rather than an engine one. If they are, run the batch work on separate capacity or off-peak.
Check Yourself
Recall the idea
What's the difference between LLM and vllm serve?
Same engine, different entry point. LLM is a Python object you pass a list of prompts to; vllm serve wraps the same engine in an OpenAI-compatible HTTP server with request lifecycle management.
Every engine argument applies to both.
Does llm.generate() apply the chat template?
No. This is documented and it's the most common source of unexpectedly poor output from instruct
models. Use llm.chat(), or apply the template yourself with the tokeniser before calling
generate().
Where do your sampling defaults come from if you pass no SamplingParams?
The model's generation_config.json from its Hugging Face repository, which vLLM applies by default.
Not vLLM's own defaults — those require generation_config="vllm".
Why does passing a list of prompts matter?
They all enter the engine's waiting queue together, so the scheduler batches them. Looping over single-prompt calls serialises the work at concurrency 1 and forfeits the engine's main advantage.
Explain the mechanics
Your instruct model gives poor answers offline but good answers through the server. Explain.
The server's /v1/chat/completions endpoint applies the chat template; llm.generate() doesn't. The
model is receiving a bare string rather than the role-marked format it was fine-tuned on, so it
continues text rather than responding to a turn. Same weights, same sampling parameters, different
input format.
Why can two people get different outputs from the same model, prompt and vLLM version?
Several ways, but the one specific to this page is generation_config.json: unless both set
SamplingParams explicitly or pass generation_config="vllm", they inherit the model author's
recommended parameters — which can differ if their model revisions differ. Add the batch-composition
non-determinism from Sampling Parameters and exact
reproduction needs more pinning than people expect.
Explain the double nesting in outputs[0].outputs[0].text.
The outer list has one RequestOutput per prompt you submitted. The inner list has one
CompletionOutput per sample, so its length is n from SamplingParams. With one prompt and n=1
you need both indices, which looks redundant until you use either feature.
When is offline benchmarking the right choice, and when is it misleading?
Right for engine capacity: it removes HTTP, serialisation and API-server CPU, so you measure the engine. Misleading for user-facing latency, because it has no arrival pattern, no queueing dynamics and no streaming — so TTFT and ITL under contention, the numbers users actually feel, aren't represented.
Reason about a trade-off
You're building an evaluation harness. Offline or online, and what do you pin?
Offline — you have a fixed list of inputs, no latency requirement, and you want the simplest thing
that reproduces. Pin the model revision, the vLLM version, and the sampling parameters explicitly
rather than inheriting generation_config.json, so results are comparable across models and over
time. Decide deliberately whether to apply the chat template: with it, you're evaluating the model as
deployed; without it, you're evaluating raw completion. Both are legitimate, but the choice must be
recorded, because it changes the numbers substantially.
A colleague's offline benchmark shows 3× the server's throughput. Is the server broken?
Probably not — they're measuring different things. Offline excludes HTTP, JSON serialisation, the
API server's tokenisation and detokenisation CPU work, and any client-side concurrency ceiling. From
Lifecycle, P0 can bottleneck before the GPU does, so a
3× gap may be entirely CPU-side. Worth checking: core count against the 2 + N process requirement,
and whether --api-server-count helps. If the gap persists with ample CPU, then it's worth
investigating.
Should an offline job set generation_config="vllm"?
If you're comparing across models or over time, yes — otherwise each model's own recommendations
become an uncontrolled variable. If you're generating output for production use, no: the author's
recommended parameters are usually well-chosen for that model, and matching production behaviour
matters more than cross-model comparability. The general rule is that measurement wants
generation_config="vllm" and production wants the model's own — and either way, setting
SamplingParams explicitly makes the question moot.
Your offline job takes six hours. Where do you look first?
Whether you're passing a list or looping — that alone can account for most of it. Then whether you're
constructing LLM more than once, since each instantiation reloads weights and re-captures CUDA
graphs. Then max_model_len, which caps concurrency for the whole run
(Stage 0). Only after those three would I
look at quantisation or a bigger GPU — the first two are free and are usually the answer.
Cheat Sheet
The minimum viable script
from vllm import LLM, SamplingParams
llm = LLM(model="Qwen/Qwen2.5-0.5B-Instruct", max_model_len=4096)
params = SamplingParams(temperature=0.7, top_p=0.95, max_tokens=256)
# instruct model → chat() applies the template
outs = llm.chat([[{"role": "user", "content": "Explain paged memory."}]], params)
print(outs[0].outputs[0].text)
# base model / throughput measurement → generate() sends the string raw
outs = llm.generate(["The capital of France is"], params)
The two surprises
| Surprise | Consequence | Fix |
|---|---|---|
generate() does not apply the chat template |
Instruct models silently produce worse output | llm.chat(), or apply it yourself |
Defaults come from the model's generation_config.json |
"Default" behaviour changes with the model | SamplingParams explicitly, or generation_config="vllm" |
Output shape
outputs[i] # RequestOutput — one per PROMPT
outputs[i].outputs[j] # CompletionOutput — one per SAMPLE (n)
outputs[i].outputs[0].text
Offline vs online
| Use offline for | Use online for |
|---|---|
| Engine capacity and throughput | User-facing TTFT and ITL |
| Evaluation suites, bulk jobs | Anything with a client waiting |
| Learning engine behaviour without networking | Queueing, streaming, cancellation |
The performance rules
- Pass a list, never loop. Looping is concurrency 1.
- Create
LLMonce and reuse it. Construction reloads weights and re-captures CUDA graphs. - Set
SamplingParamsexplicitly for anything you'll compare.
Sources
- vLLM, Quickstart — the
LLM/SamplingParamsAPI, thegeneration_config.jsondefault, the note thatllm.generatedoes not apply the chat template,llm.chat,VLLM_USE_MODELSCOPE,--attention-backend, and the Python 3.10–3.13 requirement — github.com/vllm-project/vllm/blob/main/docs/getting_started/quickstart
← Previous: Installing vLLM · Next: The OpenAI-Compatible Server →
⚠️ Verification checklist (delete before publishing)
Verified against vLLM's quickstart this session
-
llm.generatedoes not apply the chat template — stated explicitly in the docs, withllm.chatand the manualapply_chat_templateroute given as the alternatives. -
generation_config.jsonfrom the model repo is applied by default, overriding vLLM's own sampling defaults;generation_config="vllm"(offline) and--generation-config vllm(serve) opt out. -
LLM/SamplingParamsAPI shape,llm.chat(messages_list, sampling_params), and theRequestOutput→outputs[j].textnesting. - Python 3.10–3.13 supported. Stage 0 says "Python 3.10+" — now precise, worth aligning.
-
VLLM_USE_MODELSCOPE,--attention-backend(FLASH_ATTN / FLASHINFER on CUDA).
Propagation required
- Sampling Parameters presents its
temperature/
top_precommendations without mentioning that vLLM applies the model'sgeneration_config.jsonby default. That page's "Dial It In" table implies you're starting from vLLM's defaults. Add thegeneration_configcaveat there — it materially changes what "default" means. - The landing page and Stage 0 describe vLLM as Linux-first. The quickstart now documents vLLM-Metal for Apple Silicon (MLX backend, mlx-community models) as a supported path. Decide whether to mention it; at minimum the flat "Linux-first" claim needs softening.
- Stage 0 page 3 says Python 3.10+; make it 3.10–3.13.
Needs verifying
- Confirm
llm.chat()accepts a list-of-conversations and returns outputs in the same order. - Confirm
generation_config="vllm"is the exact spelling for theLLMconstructor. - Confirm whether
Qwen2.5-0.5B-Instructactually ships ageneration_config.json— if it doesn't, Experiment 2 shows no difference and needs a different model.
Code
- Run
template_matters.pyand paste both outputs into the page. Currently the difference is described rather than shown, and it's the page's central claim. - Run
whose_defaults.py; if both configurations give identical output, pick a model that does ship ageneration_config.json. - Run
batch_vs_loop.pyand capture the real ratio. -
whose_defaults.pyconstructs twoLLMobjects in one process — confirm that works on a T4 without exhausting memory, since each pre-allocates a KV cache pool. It may needgpu_memory_utilization=0.4on each, or two separate runs.
Rendering
- No diagram yet. Consider one contrasting the offline and online paths into the same engine — it would pair with the Lifecycle page's pipeline image.
- All relative links resolve once target files exist.