[RFC]: Decouple Multimodal Output Channel & Simplify Output Processor
#1,601 opened on Mar 2, 2026
Repository metrics
- Stars
- (4,990 stars)
- PR merge metrics
- (PR metrics pending)
Description
Summary
The current Output Processor in vLLM-Omni has a fundamental architectural problem: multimodal outputs (image/audio/latent) repurpose vLLM's pooler_output/pooling_output channel, causing type mismatches, semantic conflicts, and making it impossible for diffusion models to use vLLM engines as encoders. Additionally, there are numerous unnecessary per-modality methods (all with identical logic), multimodal tensors attached via setattr, divergent sync/async wrapping, and overly broad exception handling.
Core Insights:
- Multimodal outputs need a dedicated data channel and must not occupy
pooling_output.OmniModelRunnerOutputalready has amultimodal_outputsfield but it's not used in the output pipeline — it should be threaded through toEngineCoreOutputand the Output Processor. - The current 6
_process_xxx_outputmethods all do exactly the same thing. The Output Processor does not need to distinguish between modalities.
This RFC proposes:
- Separate the multimodal output channel — Add a
multimodal_outputfield throughModelRunnerOutput→EngineCoreOutput→OutputProcessor, stop repurposingpooling_output - Eliminate the false modality routing — Delete 6
_process_xxx_outputmethods and the string if/elif chain, replace with unified logic - Introduce
OutputModalityenum — Provide type safety at the configuration layer, replacing free-text strings - Introduce
MultimodalCompletionOutput— Replacesetattrdynamic attachment - Unify
OmniRequestOutput— Eliminate sync/async divergence and diffusion double-wrapping - Explicit tensor consolidation strategies — Replace audio special-casing and bare
except
Motivation
Comparison with Upstream vLLM
vLLM's OutputProcessor design is clean and well-separated:
| Component | Responsibility | Extension Method |
|---|---|---|
OutputProcessor |
Orchestration: stats → detokenize → generate RequestOutput |
Single internal loop, single entry point |
RequestState |
Per-request state management (detokenizer, logprobs) | from_new_request factory |
IncrementalDetokenizer |
Incremental detokenize + stop string detection | Fast/Slow implementations |
RequestOutput / CompletionOutput |
API-facing output data structures | Fixed fields, PoolingOutput uses separate branch |
vLLM has only two clean output paths:
- Generation path:
new_token_ids→ detokenize →CompletionOutput→RequestOutput - Pooling path:
pooling_output→PoolingOutput→PoolingRequestOutput
Routing between them is simply pooling_output is not None.
Current Problems in vLLM-Omni
vLLM-Omni introduced multimodal output needs (image, audio, latent, etc.) on top of this, but the extension approach lacks systematic design:
Problem 0 (Fundamental): Multimodal Outputs Repurpose pooling_output Channel
vLLM's pooler_output/pooling_output was designed for embedding/pooling tasks:
# vLLM ModelRunnerOutput: pooler_output: list[torch.Tensor | None] | None
# vLLM EngineCoreOutput: pooling_output: torch.Tensor | None
# vLLM Scheduler: if request.pooling_params and pooler_output is not None → FINISHED_STOPPED
vLLM-Omni stuffs multimodal outputs (dict[str, torch.Tensor]) into this channel:
# AR Model Runner: multimodal dict into pooler_output
pooler_output: list[dict[str, object]] = []
payload = {"hidden": hidden_slice}
payload.update(mm_payload) # ← multimodal mixed in
# OmniEngineCoreOutput: type override
class OmniEngineCoreOutput(EngineCoreOutput):
pooling_output: dict[str, torch.Tensor] | None = None # was torch.Tensor | None
This creates 4 severe conflicts:
| Conflict | Impact |
|---|---|
| Type mismatch | vLLM expects torch.Tensor, Omni passes dict. torch.as_tensor(dict) fails |
| Semantic conflict | Scheduler's pooling_params and pooler_output completion check triggered by multimodal outputs |
| Diffusion encoder blocked | If diffusion model needs vLLM engine as encoder, pooler_output is already occupied |
| Output Processor hack | Must set eco.pooling_output = None to undo the occupation |
Ironically, OmniModelRunnerOutput already defines multimodal_outputs: dict[str, torch.Tensor] | None but it's not used in the output pipeline.
Problem 1: False Modality Routing — 6 Methods Doing the Same Thing
# output_processor.py L381-396
if output_type == "image":
self._process_image_output(eco)
elif output_type in ("text+image", "text,image", "image+text"):
self._process_text_image_output(eco)
elif output_type in ("latents", "latent"):
self._process_latents_output(eco)
elif output_type in ("audio", "speech"):
self._process_audio_output(eco)
elif output_type == "text":
self._process_text_output(eco)
This looks like per-modality dispatch, but each method's implementation is identical:
def _process_image_output(self, eco):
if eco.pooling_output is None:
tensor = self._extract_from_multimodal_outputs(eco, keys=("image", "images", ...))
if tensor is not None:
eco.pooling_output = tensor
def _process_audio_output(self, eco):
if eco.pooling_output is None:
tensor = self._extract_from_multimodal_outputs(eco, keys=("audio", "audios", ...))
if tensor is not None:
eco.pooling_output = tensor
def _process_latents_output(self, eco):
if eco.pooling_output is None:
tensor = self._extract_from_multimodal_outputs(eco, keys=("latent", "latents", ...))
if tensor is not None:
eco.pooling_output = tensor
Moreover, _extract_from_multimodal_outputs has a fallback that grabs the first tensor in the dict:
# Try the first tensor in the dict as a fallback
for v in mm.values():
if isinstance(v, torch.Tensor):
return v
So the per-modality key lists are effectively useless. The entire routing + 6 methods = ~90 lines of code can be replaced with 3 lines.
Additionally, the string routing itself has problems:
- Same semantics have multiple aliases (
"latents"vs"latent","audio"vs"speech","text+image"vs"text,image"vs"image+text") - No compile-time type safety
Problem 2: Inconsistent Multimodal Tensor Attachment
# OmniRequestState._new_completion_output L230-240
if not hasattr(base_output, "multimodal_output"):
setattr(base_output, "multimodal_output", {})
mm_out = getattr(base_output, "multimodal_output")
multimodal_output is dynamically attached to CompletionOutput via setattr, while CompletionOutput's dataclass has no such field. Consumers must use getattr to access it. OmniRequestOutput has yet another multi-level nested access pattern:
# outputs.py L130-141
for req_out in request_outputs:
for output in getattr(req_out, "outputs", []):
if mm := getattr(output, "multimodal_output", None):
return mm
if mm := getattr(req_out, "multimodal_output", None):
return mm
Problem 3: Type Annotations Don't Match Actual Returns
OmniRequestState.make_request_output declares return type OmniRequestOutput | PoolingRequestOutput | None, but actually returns vLLM's RequestOutput via self._new_request_output().
Problem 4: Divergent Sync/Async Orchestration Wrapping
Sync path (omni.py):
output_to_yield = OmniRequestOutput(
stage_id=stage_id, final_output_type=stage.final_output_type,
request_output=engine_outputs,
)
Async path (async_omni.py):
if stage.final_output_type == "image":
output_to_yield = OmniRequestOutput(
..., images=images, finished=finished,
)
else:
output_to_yield = OmniRequestOutput(
..., finished=finished,
)
Sync doesn't pass images or finished; async branches by final_output_type. Same request produces different OmniRequestOutput fields across paths.
Problem 5: Diffusion Output Double-Wrapping
Diffusion engine directly produces OmniRequestOutput, then orchestration layer wraps again with OmniRequestOutput(request_output=existing_OmniRequestOutput), creating nesting.
Problem 6: Overly Broad Exception Handling
except Exception:
logger.exception("Error accumulating multimodal tensor")
All exceptions silently swallowed. Multimodal data may be silently lost.
Current Code Quantification
| File | Problematic Lines | Main Issues |
|---|---|---|
engine/output_processor.py |
~470 lines | False modality routing, setattr, bare except, type mismatch |
outputs.py |
~256 lines | OmniRequestOutput dual pipeline/diffusion mode |
entrypoints/omni.py |
~20 lines | Sync wrapping |
entrypoints/async_omni.py |
~20 lines | Async wrapping (diverges from sync) |
distributed/.../serialization.py |
~50 lines | multimodal_output serialization (relies on getattr) |
Total: ~816 lines with structural problems.
Design
Core Design Principles
- Multimodal outputs must have a dedicated data channel — they must not repurpose
pooler_output/pooling_output.pooling_outputreturns to its original vLLM semantics (encoder output for embedding/pooling tasks). - The Output Processor does not need to distinguish between modalities. All multimodal output processing logic is uniform: read from
multimodal_outputfield → accumulate in request state. Modality information is only meaningful at the configuration layer and tensor consolidation layer.
Overall Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ ModelRunner │
│ pooler_output ─────→ Scheduler ─→ EngineCoreOutput.pooling_output │
│ (torch.Tensor, (vLLM semantics: embedding/pooling) │
│ pooling tasks only) │
│ │
│ multimodal_output ──→ Scheduler ─→ EngineCoreOutput.multimodal_output │
│ (dict[str, Any], (new: dedicated channel for image/audio/latent) │
│ image/audio/latent) │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ MultimodalOutputProcessor │
│ │
│ Unified processing (modality-agnostic): │
│ 1. Read from eco.multimodal_output (don't touch pooling_output) │
│ 2. Accumulate into OmniRequestState.mm_accumulated │
│ 3. Base processor handles text / pooling paths normally │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ OmniRequestState → MultimodalCompletionOutput │
│ └──────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ OmniRequestOutput (unified, non-nestable) │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
I. Separate Multimodal Output Channel — Stop Repurposing pooling_output
Problem: Multimodal outputs (dict[str, torch.Tensor]) are stuffed into pooler_output/pooling_output (designed for torch.Tensor), causing type conflicts, semantic confusion, and blocking diffusion encoder scenarios.
Design: Add a dedicated multimodal_output field at every layer of the pipeline: Model Runner → Scheduler → EngineCoreOutput → Output Processor. pooler_output/pooling_output returns to vLLM's original semantics.
1.1 ModelRunnerOutput Layer
OmniModelRunnerOutput already has a multimodal_outputs field, but it's currently unused in the output pipeline. Use it directly after refactoring:
class OmniModelRunnerOutput(ModelRunnerOutput):
multimodal_outputs: dict[str, torch.Tensor] | None = None
Model Runner changes:
# Before (gpu_ar_model_runner.py): multimodal data stuffed into pooler_output
pooler_output: list[dict[str, object]] = []
payload = {"hidden": hidden_slice}
payload.update(mm_payload) # ← multimodal data mixed into pooler_output
pooler_output.append(payload)
output = OmniModelRunnerOutput(pooler_output=pooler_output, ...)
# After: pooler_output only carries hidden states (or None), multimodal uses dedicated field
pooler_output = [hidden_slice for ...] if needs_hidden else None
output = OmniModelRunnerOutput(
pooler_output=pooler_output, # torch.Tensor | None, vLLM semantics
multimodal_outputs=per_request_mm_outputs, # dict[str, list[dict]] | None, dedicated channel
...
)
1.2 EngineCoreOutput Layer
# Before
class OmniEngineCoreOutput(EngineCoreOutput):
pooling_output: dict[str, torch.Tensor] | None = None # type override, breaks vLLM contract
# After
class OmniEngineCoreOutput(EngineCoreOutput):
# pooling_output inherits base class, stays torch.Tensor | None
multimodal_output: dict[str, Any] | None = None # new dedicated field
1.3 Scheduler Layer
# Before (omni_ar_scheduler.py): multimodal dict passed through pooling_output
pooler_output = pooler_outputs[req_index] # actually a dict
outputs.append(EngineCoreOutput(pooling_output=pooler_output, ...))
# After: two separate channels
pooler_output = ... # torch.Tensor | None (pooling tasks only)
mm_output = ... # dict[str, Any] | None (multimodal only)
outputs.append(OmniEngineCoreOutput(
pooling_output=pooler_output, # vLLM semantics
multimodal_output=mm_output, # dedicated channel
...
))
# Scheduler's pooling completion check no longer mis-triggered by multimodal
if request.pooling_params and pooler_output is not None:
request.status = RequestStatus.FINISHED_STOPPED # ← only real pooling triggers this
1.4 ChunkTransferAdapter Layer
# Before: type annotation says Tensor but actually receives dict
def save_async(self, pooling_output: torch.Tensor | None, request):
...
# After: type matches reality
def save_async(self, multimodal_output: dict[str, Any] | None, request):
...
1.5 Output Processor Layer
# Before: extract multimodal from pooling_output, then set None to hack
if eco.pooling_output is not None and req_state.detokenizer is not None:
req_state.add_multimodal_tensor(eco.pooling_output)
eco.pooling_output = None # ← hack: undo occupation, force text path
# After: read from dedicated field, no hack needed
if eco.multimodal_output is not None:
req_state.add_multimodal_tensor(eco.multimodal_output)
# pooling_output untouched, base processor handles pooling/text paths normally
Impact:
pooling_outputreturns to vLLM original semantics, type restored totorch.Tensor | None- Diffusion models can use vLLM engine as encoder (
pooler_outputnot occupied) - Scheduler's
pooling_params and pooler_outputcompletion check no longer triggered by multimodal outputs - Output Processor no longer needs
eco.pooling_output = Nonehack - ChunkTransferAdapter type annotations match reality
- Multimodal outputs and pooling outputs can coexist — same request can have text + multimodal + pooling output simultaneously
II. Eliminate False Modality Routing — Unified Output Processor
Problem: 6 _process_xxx_output methods do the exact same thing; the string if/elif routing is accidental complexity.
Design: Delete all per-modality methods and routing logic. Output Processor reads from eco.multimodal_output (the new dedicated field) instead of eco.pooling_output.
Before (~90 lines):
def process_outputs(self, engine_core_outputs, ...):
for eco in engine_core_outputs:
self._route_and_normalize(eco) # ← string routing
req_state = self.request_states.get(eco.request_id)
...
if eco.pooling_output is not None and req_state.detokenizer is not None:
req_state.add_multimodal_tensor(eco.pooling_output, ...)
eco.pooling_output = None # ← hack
return super().process_outputs(...)
def _route_and_normalize(self, eco):
output_type = (getattr(eco, "output_type", ...) or "").lower()
if output_type == "image": # ← if/elif chain
self._process_image_output(eco)
elif output_type in ("text+image", "text,image", "image+text"):
self._process_text_image_output(eco)
... # 6 branches + fallback
# + 6 methods, ~8 lines each, identical logic
After (~12 lines):
class MultimodalOutputProcessor(VLLMOutputProcessor):
def __init__(self, tokenizer, log_stats, stream_interval=1,
output_modality: OutputModality = OutputModality.TEXT):
super().__init__(tokenizer=tokenizer, log_stats=log_stats,
stream_interval=stream_interval)
self._output_modality = output_modality
def process_outputs(self, engine_core_outputs, ...):
for eco in engine_core_outputs:
req_state = self.request_states.get(eco.request_id)
if not isinstance(req_state, OmniRequestState):
continue
# Read from dedicated multimodal_output field — don't touch pooling_output
mm_output = getattr(eco, "multimodal_output", None)
if mm_output is not None:
req_state.add_multimodal_tensor(mm_output)
return super().process_outputs(engine_core_outputs, ...)
Impact:
- Delete 8 methods (~90 lines), core logic ~12 lines
- No more
eco.pooling_output = Nonehack — pooling_output is untouched - No per-modality logic needed — all modalities processed identically
III. OutputModality Enum — Configuration-Layer Type Safety
Problem: engine_output_type uses free-text strings with multiple aliases for the same semantics.
Design: Introduce OutputModality enum, used only at the configuration layer and final output wrapping. The Output Processor's internal processing logic does not depend on it.
# vllm_omni/engine/output_modality.py
from enum import Flag, auto
class OutputModality(Flag):
"""Bit-flag enum — compose freely with `|`, no need to enumerate combinations.
Single: OutputModality.TEXT, OutputModality.IMAGE, ...
Compound: OutputModality.TEXT | OutputModality.IMAGE (text+image)
Note: POOLING is intentionally excluded. Pooling/embedding is vLLM's
native path (pooling_output → PoolingRequestOutput), handled entirely
by the base OutputProcessor. vLLM-Omni's layer does not participate.
"""
TEXT = auto()
IMAGE = auto()
AUDIO = auto()
LATENT = auto()
_ALIASES: ClassVar[dict[str, str]] = {
"speech": "audio",
"images": "image",
"latents": "latent",
}
@classmethod
def from_string(cls, s: str | None) -> "OutputModality":
"""Parse free-text.
"text+image" / "image,text" / "image+text" → TEXT | IMAGE
"speech" → AUDIO
"""
if not s or not s.strip():
return cls.TEXT
parts = [p.strip().lower() for p in re.split(r"[+,]", s.strip())]
result = cls(0)
for p in parts:
p = cls._ALIASES.get(p, p)
try:
result |= cls[p.upper()]
except KeyError:
raise ValueError(f"Unknown modality: {p!r}. "
f"Supported: {[m.name.lower() for m in cls]}")
return result
@property
def has_text(self) -> bool:
return OutputModality.TEXT in self
@property
def has_multimodal(self) -> bool:
return bool(self & ~OutputModality.TEXT)
Usage examples:
m = OutputModality.from_string("text+image")
assert m == OutputModality.TEXT | OutputModality.IMAGE
assert m.has_text and m.has_multimodal
# Any combination works — no pre-defined composite members needed
m2 = OutputModality.TEXT | OutputModality.AUDIO | OutputModality.IMAGE
assert m2.has_text and m2.has_multimodal
Where the enum is used (configuration and final output only):
| Location | Purpose |
|---|---|
OmniEngineArgs.output_modality |
Normalize at stage YAML parsing time |
MultimodalOutputProcessor._output_modality |
Check has_text to decide whether to preserve text path |
OmniRequestState._consolidation_strategy |
Determine tensor merge strategy |
OmniRequestOutput.output_modality |
Inform API layer of output type |
Explicitly NOT used for:
Modality routing inside Output Processor(eliminated)Per-modality processing method selection(eliminated)
IV. MultimodalCompletionOutput — Replacing setattr Attachment
Problem: multimodal_output is dynamically attached to CompletionOutput via setattr; the dataclass has no such field.
Design: Introduce MultimodalCompletionOutput inheriting CompletionOutput with multimodal_output as a first-class field.
# vllm_omni/engine/outputs.py
from dataclasses import dataclass, field
from typing import Any
import torch
from vllm.outputs import CompletionOutput
@dataclass
class MultimodalPayload:
"""Structured multimodal output payload.
Replaces dict[str, Any] with type-safe access.
"""
tensors: dict[str, torch.Tensor] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@property
def primary_tensor(self) -> torch.Tensor | None:
"""Return the first tensor."""
if self.tensors:
return next(iter(self.tensors.values()))
return None
@dataclass
class MultimodalCompletionOutput(CompletionOutput):
"""CompletionOutput with multimodal support.
Inherits all CompletionOutput fields and adds multimodal_output.
As a CompletionOutput subclass, compatible with all existing vLLM consumers.
"""
multimodal_output: MultimodalPayload | None = None
In OmniRequestState:
class OmniRequestState(RequestState):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.mm_accumulated: MultimodalPayload | None = None
def add_multimodal_tensor(self, payload: Any | None) -> None:
"""Unified tensor accumulation — modality-agnostic."""
if payload is None:
return
incoming = self._normalize_to_dict(payload)
if self.mm_accumulated is None:
self.mm_accumulated = MultimodalPayload(tensors=incoming)
else:
self._merge_tensors(incoming)
def _new_completion_output(self, token_ids, finish_reason, stop_reason, routed_experts=None):
base = super()._new_completion_output(token_ids, finish_reason, stop_reason, routed_experts)
if self.mm_accumulated is None:
return base
return MultimodalCompletionOutput(
index=base.index,
text=base.text,
token_ids=base.token_ids,
cumulative_logprob=base.cumulative_logprob,
logprobs=base.logprobs,
routed_experts=base.routed_experts,
finish_reason=base.finish_reason,
stop_reason=base.stop_reason,
multimodal_output=self.mm_accumulated,
)
Impact:
- Eliminates all
setattr/getattrdynamic attribute operations multimodal_outputbecomes a formal field — IDE completion + type checkingadd_multimodal_tensorno longer takesmm_typeparameter — processing logic is the same for all modalities
V. Unified OmniRequestOutput — Eliminating Double-Wrapping
Problem: OmniRequestOutput serves both pipeline and diffusion modes with mixed fields; diffusion outputs get double-wrapped; sync/async construction logic diverges.
Design: Refactor OmniRequestOutput into a non-nestable unified output container, and provide a _wrap_output unified constructor function shared by sync/async orchestration layers.
@dataclass
class OmniRequestOutput:
"""Unified request output. Non-nestable."""
request_id: str
finished: bool = True
output_modality: OutputModality = OutputModality.TEXT
# Base output from vLLM
request_output: RequestOutput | PoolingRequestOutput | None = None
# Multimodal output
multimodal: MultimodalPayload | None = None
# Images (Diffusion)
images: list[Image.Image] = field(default_factory=list)
# Metadata
stage_id: int | None = None
prompt: OmniPromptType | None = None
metrics: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_engine_output(cls, request_output, stage_id, output_modality):
"""Construct from engine's RequestOutput. Shared by sync/async."""
multimodal = None
for comp_out in getattr(request_output, "outputs", []):
if isinstance(comp_out, MultimodalCompletionOutput) and comp_out.multimodal_output:
multimodal = comp_out.multimodal_output
break
return cls(
request_id=request_output.request_id,
finished=request_output.finished,
output_modality=output_modality,
request_output=request_output,
multimodal=multimodal,
stage_id=stage_id,
)
@classmethod
def from_diffusion(cls, request_id, images, ...):
"""Construct from Diffusion engine. No double-wrapping."""
return cls(request_id=request_id, finished=True,
output_modality=OutputModality.IMAGE, images=images, ...)
Orchestration layer unification:
# Shared by omni.py and async_omni.py
def _wrap_output(engine_output, stage_id, output_modality):
if isinstance(engine_output, OmniRequestOutput):
engine_output.stage_id = stage_id
return engine_output
return OmniRequestOutput.from_engine_output(engine_output, stage_id, output_modality)
VI. Tensor Consolidation Strategies
Problem: _consolidate_multimodal_tensors has audio special-case skip and bare except.
Design: Bind strategy to OutputModality — this is where modality distinction is actually meaningful, since different modalities have different tensor shape semantics.
class TensorAccumulationStrategy(Enum):
CONCAT_DIM0 = "concat_dim0" # image / latent
CONCAT_LAST = "concat_last" # audio
APPEND_LIST = "append_list"
REPLACE = "replace"
def get_accumulation_strategy(modality: OutputModality) -> TensorAccumulationStrategy:
"""Determine tensor merge strategy from the multimodal flags.
Uses Flag bit checks — no need to enumerate combinations.
"""
if OutputModality.AUDIO in modality:
return TensorAccumulationStrategy.CONCAT_LAST
if OutputModality.IMAGE in modality or OutputModality.LATENT in modality:
return TensorAccumulationStrategy.CONCAT_DIM0
return TensorAccumulationStrategy.CONCAT_DIM0 # default
VII. Exception Handling — Explicit Propagation
Design: Distinguish recoverable from non-recoverable exceptions.
class MultimodalOutputError(Exception):
"""Base class for multimodal output processing errors."""
pass
class TensorAccumulationError(MultimodalOutputError):
"""Tensor accumulation failed (recoverable: skip this step's tensor)."""
pass
class TensorConsolidationError(MultimodalOutputError):
"""Tensor consolidation failed (non-recoverable: request should be marked as failed)."""
pass
| Exception Type | Handling |
|---|---|
TensorAccumulationError |
Log warning + skip this step's tensor |
TensorConsolidationError |
Log error + set mm_accumulated = None + record in metrics |
| Other | Propagate upstream |
Data Flow Comparison
Before:
OmniModelRunnerOutput (multimodal_outputs field unused)
pooler_output = [{"hidden": tensor, "audio": tensor}, ...] ← mm dict stuffed in
→ Scheduler
pooler_output → EngineCoreOutput.pooling_output ← type: Tensor → dict
pooling_params and pooler_output → false completion ← semantic conflict
→ MultimodalOutputProcessor._route_and_normalize() ← string if/elif
→ _process_audio_output / _process_image_output / ... ← 6 methods (same logic)
→ req_state.add_multimodal_tensor(eco.pooling_output)
→ eco.pooling_output = None ← hack: undo occupation
→ super().process_outputs()
→ setattr(base_output, "multimodal_output", ...) ← dynamic attachment
→ Orchestrator → OmniRequestOutput(request_output=...) ← sync/async diverge
→ API → getattr(output, "multimodal_output", None) ← duck typing
After:
OmniModelRunnerOutput
pooler_output = [tensor, ...] ← vLLM semantics (Tensor | None)
multimodal_outputs = {"audio": tensor, ...} ← dedicated channel
→ Scheduler
pooler_output → EngineCoreOutput.pooling_output ← correct type: Tensor | None
mm_output → OmniEngineCoreOutput.multimodal_output ← dedicated field
(pooling_params completion check unaffected)
→ MultimodalOutputProcessor.process_outputs()
→ req_state.add_multimodal_tensor(eco.multimodal_output) ← read from dedicated field
(pooling_output untouched, no hack needed)
→ super().process_outputs()
→ MultimodalCompletionOutput(multimodal_output=...) ← subclass, formal field
→ Orchestrator → _wrap_output() → OmniRequestOutput ← unified function
→ API → output.multimodal.primary_tensor ← type-safe access
Architecture Diagram
classDiagram
class OmniEngineCoreOutput {
+pooling_output: Tensor | None
+multimodal_output: dict | None
}
class OutputModality {
<<Flag>>
TEXT
IMAGE
AUDIO
LATENT
+from_string(s) OutputModality
+has_text bool
+has_multimodal bool
}
class TensorAccumulationStrategy {
<<enum>>
CONCAT_DIM0
CONCAT_LAST
APPEND_LIST
REPLACE
}
class MultimodalPayload {
+tensors: dict
+metadata: dict
+primary_tensor Tensor
}
class MultimodalCompletionOutput {
+multimodal_output: MultimodalPayload
}
class OmniRequestState {
+mm_accumulated: MultimodalPayload
+add_multimodal_tensor(payload)
+_consolidate_multimodal_tensors()
}
class MultimodalOutputProcessor {
-_output_modality: OutputModality
+process_outputs()
}
class OmniRequestOutput {
+output_modality: OutputModality
+multimodal: MultimodalPayload
+request_output: RequestOutput
+from_engine_output()
+from_diffusion()
}
EngineCoreOutput <|-- OmniEngineCoreOutput
OmniEngineCoreOutput ..> MultimodalOutputProcessor : multimodal_output
MultimodalOutputProcessor --> OutputModality
MultimodalOutputProcessor --> OmniRequestState
OmniRequestState --> MultimodalPayload
OmniRequestState --> TensorAccumulationStrategy
MultimodalCompletionOutput --> MultimodalPayload
OmniRequestOutput --> MultimodalPayload
OmniRequestOutput --> OutputModality
CompletionOutput <|-- MultimodalCompletionOutput
RequestState <|-- OmniRequestState
VLLMOutputProcessor <|-- MultimodalOutputProcessor
Migration Plan
Phase 1: Foundation Types (0.5 day, lowest risk)
- Create
vllm_omni/engine/output_modality.py—OutputModalityenum +TensorAccumulationStrategy - Create
vllm_omni/engine/outputs.py—MultimodalPayload+MultimodalCompletionOutput - Add
output_modalityproperty toOmniEngineArgs - Introduce only, no old logic replaced — zero regression
Phase 2: Separate Multimodal Output Channel (1.5 days, most critical)
- Modify
OmniEngineCoreOutput: addmultimodal_output: dict[str, Any] | None, restorepooling_outputto inherit base classtorch.Tensor | None - Modify
gpu_ar_model_runner.py: multimodal data no longer stuffed intopooler_output, useOmniModelRunnerOutput.multimodal_outputsinstead;pooler_outputonly holds hidden states tensor orNone - Modify
gpu_generation_model_runner.py: same - Modify
omni_ar_scheduler.py: extractpooler_output(→pooling_output) andmultimodal_outputs(→multimodal_output) separately fromOmniModelRunnerOutput - Modify
omni_generation_scheduler.py: same - Modify
ChunkTransferAdapter.save_async: parameter frompooling_output: torch.Tensortomultimodal_output: dict[str, Any] - End-to-end validation: Qwen2.5-Omni, Qwen3-Omni, GLM-Image, MiMoAudio
Phase 3: Simplify Output Processor + OmniRequestState Refactoring (1 day)
MultimodalOutputProcessor.__init__acceptsOutputModality- Rewrite
process_outputs: read fromeco.multimodal_output, don't touchpooling_output - Delete
_route_and_normalize, 6_process_xxx_outputmethods, etc. (~90 lines) mm_accumulatedtype →MultimodalPayload | None_new_completion_outputreturnsMultimodalCompletionOutput- Replace bare
except Exceptionwith precise exception types
Phase 4: OmniRequestOutput Unification + Orchestration (1 day)
- Refactor
OmniRequestOutput: addoutput_modality,multimodalfields; implement factory methods - Implement
_wrap_outputunified wrapping function - Update
omni.pyandasync_omni.pyto use_wrap_output - Update serialization code for
MultimodalPayload
Phase 5: Cleanup (0.5 day)
- Delete old code and compatibility layers
- Update stage YAML documentation
Directory Structure
vllm_omni/engine/
├── __init__.py # OmniEngineCoreOutput
├── output_modality.py # OutputModality enum + TensorAccumulationStrategy [NEW]
├── outputs.py # MultimodalPayload + MultimodalCompletionOutput [NEW]
├── output_processor.py # MultimodalOutputProcessor (greatly slimmed) + OmniRequestState
├── arg_utils.py # OmniEngineArgs (add output_modality property)
└── ...
vllm_omni/
├── outputs.py # OmniRequestOutput (refactored) + OmniModelRunnerOutput
└── ...
Testing Strategy
| Layer | Test Method |
|---|---|
OutputModality.from_string |
Unit: all known aliases normalize correctly, unknown strings raise ValueError |
MultimodalPayload |
Unit: tensor store/retrieve, primary_tensor access |
MultimodalCompletionOutput |
Unit: inherits all CompletionOutput fields + multimodal_output |
MultimodalOutputProcessor |
Unit: unified processing behaves identically for image/audio/latent |
OmniRequestState |
Unit: add_multimodal_tensor accumulation; each TensorAccumulationStrategy |
OmniRequestOutput |
Unit: from_engine_output, from_diffusion; compatibility properties |
| End-to-end | Qwen2.5-Omni (audio), Qwen3-Omni (audio), GLM-Image (image), MiMoAudio (audio) |
| Regression | All existing tests preserved, full CI after each Phase |
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Phase 2 touches Model Runner + Scheduler + Transfer Adapter full chain | Validate per-model: fix AR runner first (Qwen2.5-Omni), verify, then Generation runner |
After pooler_output separation, downstream stage input processors depending on hidden key need adaptation |
Check all custom_process_next_stage_input_func implementations, unify to read from multimodal_output |
New field on OmniEngineCoreOutput affects msgspec serialization |
multimodal_output set with omit_defaults=True, only serialized when present |
MultimodalCompletionOutput incompatible with vLLM serving type checks |
Is CompletionOutput subclass, isinstance passes |
OutputModality.from_string misses non-standard strings |
Phase 1 scans entire codebase for all engine_output_type values |
| Serialization compatibility | Phase 4 updates serialization.py simultaneously |
Out of Scope
| Area | Rationale |
|---|---|
| Streaming multimodal output | Needs separate RFC |
| IOProcessor plugin system | vLLM pooling post-processing, defer to later |
| Multimodal OpenAI API compatibility | Serving layer concern |
OmniOutput model internal template |
Model executor layer (RFC-003) |
additional_information protocol |
Independent infrastructure concern |
Open Questions
- After separation, should the AR Model Runner's
pooler_outputstill carry hidden states? Or should hidden states also go throughmultimodal_output? Currentlypooler_output = [{"hidden": tensor, ...}]blurs the line between inter-stage data and pooling semantics. - Should
TensorAccumulationStrategybe declared by the model side (via stage config) rather than bound toOutputModalityin the output processor layer? - Does
MultimodalPayload.tensorsneed stronger types, or isdict[str, torch.Tensor]flexibility sufficient? - Should diffusion pipelines use
MultimodalPayloadinternally for full unification? - Do we need to support simultaneous multi-modality combinations (e.g., image + audio) at the
OmniRequestOutputlevel? - Should the Scheduler have completion logic for
multimodal_output(i.e.,multimodal_output is not None→ mark finished), similar to pooling, or should completion be driven entirely byfinish_reason/new_token_ids?
Feedback Period.
No response
CC List.
@hsliuustc0106 @ZJY0516 @princepride @Gaohan123
Any Other Things.
No response
Before submitting a new issue...
- Make sure you already searched for relevant issues, and asked the chatbot living at the bottom right corner of the documentation page, which can answer lots of frequently asked questions.