deepinfer/entrypoints/llm.py: the offline LLM class
Code version: DeepInfer @ 9bd9cf2 · 2026-10-21
Purpose#
LLM is the class that a user script calls. It turns a list of prompts into a list of results, and hides the engine step loop.
| File | deepinfer/entrypoints/llm.py, 198 lines. The header says "Derived from vLLM; modified by DeepSeek." (deepinfer/entrypoints/llm.py:2) |
|---|---|
| Main class | LLM (deepinfer/entrypoints/llm.py:23). It is the only class in the file. |
| Runs in | The user's process. That process is also the engine process of one data-parallel rank, with the driver worker in it. The step loop runs on the calling thread, and generate() blocks until all requests end. |
| Called by | User scripts, examples/_config.py (create_llm), eval/run_eval.py. |
| Calls | EngineArgs, LLMEngine, the Hugging Face tokenizer, RequestOutput.clone, tqdm. |
| Not in this file | Batching, scheduling, sampling (all in the engine). No streaming, no abort, no online server: the engine docstring says that this release has no online-serving wrapper (deepinfer/engine/llm_engine.py:140-141). |
Where it sits#
Panel 1 runs once per prompt and does no device work. Panel 2 is the loop. The flush runs only once, at the end.
Who calls it#
- The examples make the object in
create_llm, withtrust_remote_code=True,chunked_prefill=True,decode_sequence=Falseand statistics on (examples/_config.py:110-125).examples/offline_inference.pythen callsgenerate(examples/offline_inference.py:95). - The eval script uses
decode_sequence=Falseand passes token ids (eval/run_eval.py:189). - The benchmark uses the private method
llm._add_requestand then callsllm.llm_engine.step()itself (examples/benchmark.py:304-309,examples/benchmark.py:369).
What it calls#
EngineArgs(...)andLLMEngine.from_engine_args(deepinfer/entrypoints/llm.py:70-84). See engine/llm_engine.py: the LLMEngine class.tokenizer.encodeandtokenizer.decode(deepinfer/entrypoints/llm.py:156,deepinfer/entrypoints/llm.py:190).LLMEngine.add_request,has_unfinished_requests,step,flush_inflight_reqs,get_num_unfinished_requestsandclose.RequestOutput.clonefrom the C++ extension (deepinfer/entrypoints/llm.py:15). See csrc/engine/outputs.hpp: RequestOutput and its pool.
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
LLM.__init__ | deepinfer/entrypoints/llm.py:54 | Sets three defaults, makes EngineArgs and the engine. |
get_tokenizer | deepinfer/entrypoints/llm.py:87 | Returns the engine's tokenizer. |
generate | deepinfer/entrypoints/llm.py:90 | Checks the inputs, adds all requests, runs the loop. |
_add_request | deepinfer/entrypoints/llm.py:144 | Copies the settings, makes the id, tokenizes, calls add_request. |
_run_engine | deepinfer/entrypoints/llm.py:168 | The step loop: clone, group, sort, decode. |
close | deepinfer/entrypoints/llm.py:197 | Calls LLMEngine.close(). |
Walk through the main methods#
__init__: defaults that differ from the engine#
def __init__(
self,
model: str,
tokenizer: str | None = None,
trust_remote_code: bool = False,
expert_parallel_size: int = 1,
revision: str | None = None,
tokenizer_revision: str | None = None,
seed: int = 0,
gpu_memory_utilization: float = 0.9,
decode_sequence: bool = True,
**kwargs: Any,
) -> None:
kwargs.setdefault("disable_log_stats", True)
kwargs.setdefault("stop_preempted_request", False)
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
engine_args = EngineArgs(
model=model,
tokenizer=tokenizer,
trust_remote_code=trust_remote_code,
expert_parallel_size=expert_parallel_size,
revision=revision,
tokenizer_revision=tokenizer_revision,
seed=seed,
gpu_memory_utilization=gpu_memory_utilization,
offline_inference=True,
**kwargs,
)
self.decode_sequence = decode_sequence
self.llm_engine = LLMEngine.from_engine_args(engine_args)
self.request_counter = Counter()- Lines 56-64: nine named arguments. All other arguments go through
**kwargstoEngineArgs, for examplemax_model_len,max_num_seqsorchunked_prefill. An unknown name raisesTypeError, becauseEngineArgsis a dataclass. - Line 67: statistics logging is off, unless you pass
disable_log_stats=False. - Line 68:
stop_preempted_request=False. TheEngineArgsdefault isTrue(deepinfer/engine/arg_utils.py:40). So withLLM, a preempted request is computed again, not stopped (see The KV cache). - Line 69:
TOKENIZERS_PARALLELISM=false, unless the variable is already set. - Line 79:
offline_inference=True. The engine stores it (deepinfer/engine/llm_engine.py:195); no other line in the release reads it. - Line 82:
decode_sequencechooses the result type ofgenerate()(text or outputs).
The docstring has a note in Chinese. Translated: "With CUDA graphs on, the error 'CUDA error: an illegal memory access was encountered' can mean that the CUDA graph ran out of memory. Lower gpu_memory_utilization and try again." (deepinfer/entrypoints/llm.py:50-51).
generate: the input rules#
if prompts is None and prompt_token_ids is None:
raise ValueError("Either prompts or prompt_token_ids must be provided.")
if isinstance(prompts, str):
# Convert a single prompt to a list.
prompts = [prompts]
multi_modal_data = [multi_modal_data]
if prompts is not None and prompt_token_ids is not None and len(prompts) != len(prompt_token_ids):
raise ValueError("The lengths of prompts and prompt_token_ids must be the same.")
if sampling_params is None:
# Use default sampling params.
sampling_params = SamplingParams()
# Add requests to the engine.
num_requests = len(prompts) if prompts is not None else len(prompt_token_ids)
for i in range(num_requests):
prompt = prompts[i] if prompts is not None else None
token_ids = None if prompt_token_ids is None else prompt_token_ids[i]
self._add_request(
prompt,
# 共享同一个 SamplingParams 时无需在此复制:_add_request 内部会 deepcopy
sampling_params if isinstance(sampling_params, SamplingParams) else sampling_params[i],
token_ids,
None if multi_modal_data is None else multi_modal_data[i],
)
return self._run_engine(use_tqdm)- Lines 117-118: you must give
prompts,prompt_token_ids, or both. - Lines 119-122: one string becomes a list of one.
multi_modal_datais wrapped too, so pass one dict for one string. - Lines 123-124: if you give both lists, they must have the same length. Then the token ids win;
_add_requesttokenizes only when the ids areNone(deepinfer/entrypoints/llm.py:153-156). - Lines 125-127: no settings means one
SamplingParams()for all requests: temperature 1.0, 16 tokens (deepinfer/sampling_params.py:49-53). - Line 138: one object for all, or a list with one object per prompt. The comment says, translated: "when the same SamplingParams is shared, no copy is needed here: _add_request does a deepcopy" (line 137).
_add_request: one request#
sampling_params = copy.deepcopy(sampling_params)
request_id = str(next(self.request_counter))
if prompt_token_ids is None:
if prompt is None:
raise ValueError("prompt and prompt_token_ids cannot both be None")
prompt_token_ids = self.llm_engine.tokenizer.encode(prompt)
prompt_token_ids = np.asarray(prompt_token_ids, dtype=np.int32)
if multi_modal_data is None and self.llm_engine.multi_modal_processor is not None:
multi_modal_data = dict(image_bytes=[], image_sizes=[])
return self.llm_engine.add_request(
user_id="0", # HACK 随便加一个 user_id
request_id=request_id,
sampling_params=sampling_params,
prompt_token_ids=prompt_token_ids,
multi_modal_data=multi_modal_data,
)Translated comments (Chinese → English)
- 161 HACK: add some user_id.
- Line 151: a deep copy, because the engine changes the object (it sets
eos_id, see deepinfer/sampling_params.py: SamplingParams). - Line 152: the request id is the next value of a counter that starts at 0, as a string. The counter lives in the
LLMobject, so a secondgenerate()call goes on from the last id. - Line 156: text goes through
tokenizer.encode(prompt). With token ids, DeepInfer uses them as they are and adds no BOS token. - Line 157: the engine needs a 1-D
np.int32array; the C++Sequencerefuses other types (see deepinfer/_C/engine.pyi: the C++ engine API, as seen from Python). - Lines 158-159: if the engine has an image processor and you gave no image data, the request gets an empty image dict. The only registered model is a vision-language model, so the NORMAL engine always has that processor (
deepinfer/model_executor/models/__init__.py:15-17,deepinfer/engine/llm_engine.py:209-221). - Line 160: the return value is a
Sequence, orNonefor a dropped request.generate()ignores it.
_run_engine: the loop#
def _run_engine(self, use_tqdm: bool) -> list[list[RequestOutput] | str]:
# Initialize tqdm.
if use_tqdm:
num_requests = self.llm_engine.get_num_unfinished_requests()
pbar = tqdm(total=num_requests, desc="Processed prompts")
# Run the engine.
outputs: dict[str, list[RequestOutput]] = defaultdict(list)
while True:
unfinished = self.llm_engine.has_unfinished_requests()
step_outputs = self.llm_engine.step() if unfinished else self.llm_engine.flush_inflight_reqs()
for raw_output in step_outputs:
# clone step_outputs since step_outputs will be released in the next step
output = RequestOutput.clone(raw_output)
outputs[output.request_id].append(output)
if output.finished and use_tqdm:
pbar.update(1)
if not unfinished:
break
request_ids = sorted(outputs, key=int)
results: list[list[RequestOutput] | str] = [outputs[i] for i in request_ids]
if self.decode_sequence:
results = [self.llm_engine.tokenizer.decode([x.token_id for x in output]) for output in results]
if use_tqdm:
pbar.close()
return resultsTranslated comments (Chinese → English)
- 180 Clone step_outputs, because step_outputs will be released in the next step.
- Line 171: the progress bar counts local unfinished requests at the start.
- Line 177:
has_unfinished_requests()asks all data-parallel ranks with an all-reduce MAX, when there is more than one rank (deepinfer/engine/llm_engine.py:665-679). So this rank goes on while any rank has work. - Line 178: work left: one engine step. No work left: one flush, which returns the outputs that the Worker still holds from the last ZOS decode step (
deepinfer/worker/worker.py:274-283). - Lines 179-182: every handle is cloned at once. The engine releases the pool objects at the next
pre_sampler_sync(deepinfer/engine/llm_engine.py:830-831). - Lines 185-186: the loop ends after the flush.
- Line 187: the request ids are sorted as integers, so
"10"comes after"9". - Lines 189-190: with
decode_sequence=True, each request becomes one string: the decode of all its output token ids.
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveData flow: types at each point
| Point | Type | Source |
|---|---|---|
prompts | str | list[str] | None | deepinfer/entrypoints/llm.py:92 |
prompt_token_ids | list[list[int]] | list[np.ndarray[np.int32]] | None | deepinfer/entrypoints/llm.py:94 |
multi_modal_data | dict | list[dict] | None; keys such as image_bytes, image_sizes | deepinfer/entrypoints/llm.py:96, deepinfer/entrypoints/llm.py:159 |
| to the engine | 1-D np.int32 array, a SamplingParams copy, request_id: str | deepinfer/entrypoints/llm.py:157-166 |
step() result | np.ndarray[np.uint64]: handles | deepinfer/engine/llm_engine.py:860 |
outputs | defaultdict(list): request_id → list[RequestOutput], one per token | deepinfer/entrypoints/llm.py:175 |
result, decode_sequence=False | list[list[RequestOutput]] | deepinfer/entrypoints/llm.py:188 |
result, decode_sequence=True | list[str] | deepinfer/entrypoints/llm.py:190 |
The return annotation says list[list[RequestOutput] | str] (line 97); the docstring says "a list of RequestOutput objects" (lines 113-115). The code returns one of the two list types above, never a mix.
One output per token. Each RequestOutput holds one token (RequestOutput). A decode step with accepted drafts gives several outputs for one request (see csrc/engine/outputs.hpp: RequestOutput and its pool). To get the token ids of request i: [o.token_id for o in results[i]], as line 190 does.
Deep diveWorked example: the calls of one generate()
Two prompts, A and B, each with max_tokens=3. ZOS is on (the default for LLM). To keep the table short, assume that the sampler accepts no draft, so each decode step gives one token per request.
| Call | What the engine runs | Handles returned |
|---|---|---|
1 to 70: step() | empty decode steps. A and B become schedulable only in the fetch() after the first schedule (deepinfer/engine/llm_engine.py:939-941). The next prefill attempt point is decode_step = 70 (csrc/engine/scheduler.hpp:308-313). See Tour: Life of a request. | none |
71: step() | prefill A and B; a prefill step is post-processed in the same call (deepinfer/worker/worker.py:183-185) | A token 1, B token 1 |
72: step() | decode step d1; nothing is in flight yet | none |
73: step() | decode d2; post-process d1 (deepinfer/worker/worker.py:143-144) | A token 2, B token 2 |
74: step() | decode d3 (A and B are still in running); post-process d2: both reach max_tokens | A token 3, B token 3 (finished) |
75: step() | the scheduler moves A and B to finished_seqs; an empty step; post-process d3 gives nothing, because both already ended (csrc/engine/scheduler.hpp:906-915) | none |
| 76: flush | has_unfinished_requests() is false; the flush post-processes the empty step | none |
Deep diveSeeds: the LLM seed and the request seed
There are two seeds, and they do different things.
LLM(seed=0)goes toModelConfig.seed. The Worker callsset_random_seed(seed + global_rank)for Pythonrandom, NumPy and PyTorch, at start-up and again after the memory profile and graph capture (deepinfer/worker/base.py:170-172,deepinfer/worker/base.py:194,deepinfer/worker/base.py:286-288,deepinfer/model_executor/utils.py:13-18).SamplingParams(seq_seed=...)is the seed that the sampling kernels use for that request. Without it, the constructor takes 64 bits from Pythonrandom(deepinfer/sampling_params.py:86-88). The Worker runs in the same process, so aSamplingParamsobject made afterLLM(...)takes its bits from the state thatLLM(seed)set.
Deep diveProcesses, threads and data parallelism
- One process per data-parallel rank. Each rank makes its own
LLM.examples/_launch.pystarts the processes (examples/_launch.py:35-44). - All ranks loop together.
has_unfinished_requests()and the scheduler's votes are collective calls (deepinfer/engine/llm_engine.py:675-678). So a rank with no prompts also callsgenerate(), and runs empty steps until all ranks are done. - Context-parallel ranks are follower processes that the engine starts;
LLMdoes not see them (deepinfer/engine/llm_engine.py:377-395). - Threads.
generate()is not thread-safe: two threads would share one engine and one counter. Tokenization runs on the calling thread;TOKENIZERS_PARALLELISM=falseturns off the tokenizer's own thread pool (line 69).
Deep diveError paths and surprises
| Situation | Result | Where |
|---|---|---|
| No prompts and no token ids | ValueError | deepinfer/entrypoints/llm.py:117-118 |
| Two lists of different length | ValueError | deepinfer/entrypoints/llm.py:123-124 |
A list of prompts with one multi_modal_data dict | KeyError: 0 (the dict is indexed with i) | deepinfer/entrypoints/llm.py:140 |
Fewer SamplingParams than prompts | IndexError | deepinfer/entrypoints/llm.py:138 |
| An empty prompt | ValueError("prompt must not be empty") from the engine | deepinfer/engine/llm_engine.py:553-554 |
| A token id out of range | log line; the request has no result (see the warning above) | deepinfer/engine/llm_engine.py:582-589 |
| A prompt longer than the limit | one output with finish_reason = "prompt_length_exceeded"; its token is the last prompt token, so the decoded text is that token | csrc/engine/outputs.hpp:297-304 |
| Unknown keyword argument | TypeError from EngineArgs | deepinfer/entrypoints/llm.py:70-81 |
The EOS token in the text. The last output of a request that stops on EOS holds the EOS token itself. The scheduler appends a token before it checks the stop rule (csrc/engine/scheduler.hpp:983-988). Line 190 calls decode without skip_special_tokens, so the text can end with the EOS token's text.
Deep diveEnvironment variables that this file touches
| Variable | What the file does | Line |
|---|---|---|
TOKENIZERS_PARALLELISM | sets false if not set | deepinfer/entrypoints/llm.py:69 |
DEEPINFER_ZERO_OVERHEAD_SCHEDULING | not read here; EngineArgs sets 1 for the NORMAL engine when the variable is not set, which explains the flush | deepinfer/engine/arg_utils.py:51-55 |
Related concepts#
- Offline API and the step loop: the step loop from the user's view.
- engine/llm_engine.py: the LLMEngine class: the engine that
LLMwraps. - deepinfer/sampling_params.py: SamplingParams: the settings of each request.
- csrc/engine/outputs.hpp: RequestOutput and its pool: why each handle needs a clone.
- Zero-overhead scheduling (ZOS): why outputs come one call late and the flush exists.
- Tour: Life of a request: one request from
generate()to text.
Check yourself#
- 1What doeseasy
generate()return with the defaultdecode_sequence=True, and withFalse?Show answer
True: one string per request, the decode of its output token ids.False: onelist[RequestOutput]per request, one object per token (deepinfer/entrypoints/llm.py:187-190). - 2Which defaults doesmedium
LLMchange compared withEngineArgs, and what is the effect of the second one?Show answer
disable_log_stats=Trueandstop_preempted_request=False(deepinfer/entrypoints/llm.py:67-68). With the second one, a preempted request goes back to the waiting queue and is computed again, instead of an early stop withinsufficient_system_resource(csrc/engine/scheduler.hpp:771-788). - 3Why doesmedium
_run_enginecallflush_inflight_reqs()after the laststep()?Show answer
With ZOS, the Worker post-processes a decode step during the next call. When no work is left, the outputs of the last step are still in the Worker; the flush post-processes them (
deepinfer/worker/worker.py:182-187,deepinfer/worker/worker.py:274-283). - 4You callhard
llm.generate(["Hi"] * 4)with no sampling parameters. Why can all four texts be the same?Show answer
generate()makes oneSamplingParams(), which draws one randomseq_seedin its constructor (deepinfer/entrypoints/llm.py:125-127,deepinfer/sampling_params.py:74)._add_requestdeep-copies that object for each request, so all four copies have the same seed (line 151). The sampler draws its random numbers from the seed and the position, so all four requests get the same numbers. If their logits are also equal, they get the same tokens. This is the deep dive "Seeds".