vLLM Explained

KV connectors

AdvancedvLLM conceptsabout 11 min read + 6 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

A KV connector moves KV blocks in and out of vLLM; its scheduler half plans each step and its worker half moves the bytes.

The idea#

The KV cache keeps the keys and values of earlier tokens, so decode does not compute them again (The KV cache). Prefix caching reuses these blocks inside one vLLM instance (Prefix caching in the engine core: hashes, lookup, caching, eviction and KV events). A KV connector goes further: it moves blocks to or from something outside the instance. Examples are a second vLLM instance (disaggregated prefill), CPU memory, a local disk or a distributed store.

The work has two parts that run in different processes. The scheduler must know, before it allocates blocks, how many prompt tokens it can load instead of compute. The workers must copy the bytes into or out of the GPU. So one connector class is made twice, with a different connector role. The SCHEDULER object lives in the EngineCore process. The WORKER object lives in each worker. Each step, the scheduler side writes a metadata object; the worker side reads it.

You turn a connector on with --kv-transfer-config, a KVTransferConfig. Its field kv_connector names the class. KVConnectorFactory maps names to classes. vLLM registers 17 built-in connectors, and a plugin can register more, or name a module path.

An everyday comparison. Think of a restaurant kitchen with an external store room. Before each round of orders, the head chef checks the store list for the sauces of each order. If they are in the store, she reserves a shelf and writes a pick list. The kitchen porters read the list, bring the items to the shelf, and take finished items back to the store. The comparison stops here. In vLLM the porters (workers) can also carry items layer by layer during cooking, and the chef can postpone an order until its items arrive.

The picture#

Figure: the connector calls of one step. Blue runs in the scheduler's schedule(), violet in each worker, green in the scheduler's update_from_output().
Open full size ↗

Follow the numbers. Steps 1-3 run on the CPU in the engine core, before the step goes to the workers. Steps 4-6 run in every worker around the forward pass. Steps 7-9 run in the engine core when the outputs come back, and in the next schedule.

In the code#

Step 1: the configuration#

@config
class KVTransferConfig:
    """Configuration for distributed KV cache transfer."""

    kv_connector: str | None = None
    """The KV connector for vLLM to transmit KV caches between vLLM instances.
    """

    engine_id: str | None = None
    """The engine id for KV transfers."""

    kv_buffer_device: str = field(default_factory=kv_buffer_device_default_factory)
    """The device used by kv connector to buffer the KV cache. Choices are
    'cuda', 'cpu' and 'xpu'."""

    kv_role: KVRole | None = None
    """Whether this vLLM instance produces, consumes KV cache, or both. Choices
    are 'kv_producer', 'kv_consumer', and 'kv_both'."""

    kv_port: int = 14579
    """The KV connector port, used to build distributed connection."""

    kv_connector_extra_config: dict[str, Any] = field(default_factory=dict)
    """any extra config that the connector may need."""

    kv_connector_module_path: str | None = None
    """The Python module path to dynamically load the KV connector from.
    Only supported in V1."""

    enable_permute_local_kv: bool = False
    """Experiment feature flag to enable HND to NHD KV Transfer"""

    kv_load_failure_policy: Literal["recompute", "fail"] = "fail"
    """Policy for handling KV cache load failures.
    'recompute': reschedule the request to recompute failed blocks
    'fail': immediately fail the request with an error finish reason (default)"""
  • kv_connector is the registered name or the class name. kv_role is kv_producer, kv_consumer or kv_both; a connector without a role raises (vllm/config/kv_transfer.py:120-124).
  • kv_connector_extra_config is a free dict for the connector, for example cpu_bytes_to_use.
  • kv_load_failure_policy defaults to "fail": a failed load fails the request.
  • engine_id becomes a random UUID when unset (vllm/config/kv_transfer.py:111-112).

The producer and consumer properties decide some scheduler behaviour (vllm/config/kv_transfer.py:126-136). A typical disaggregated setup runs one instance with kv_producer and one with kv_consumer; the page Disaggregated prefill: prefill and decode on different instances explains it. This command is adapted from the docs (docs/features/disagg_prefill.md:41):

vllm serve Qwen/Qwen3-8B --kv-transfer-config \
  '{"kv_connector":"OffloadingConnector","kv_role":"kv_both","kv_connector_extra_config":{"cpu_bytes_to_use":1000000000}}'

Step 2: the factory finds the class#

    @classmethod
    def get_connector_class(
        cls, kv_transfer_config: "KVTransferConfig"
    ) -> type[KVConnectorBaseType]:
        connector_name = kv_transfer_config.kv_connector
        if connector_name is None:
            raise ValueError("Connector name is not set in KVTransferConfig")
        connector_module_path = kv_transfer_config.kv_connector_module_path
        if connector_module_path is not None and not connector_module_path:
            raise ValueError("kv_connector_module_path cannot be an empty string.")
        if connector_module_path:
            # External module path takes priority over internal registry.
            connector_module = importlib.import_module(connector_module_path)
            try:
                connector_cls = getattr(connector_module, connector_name)
            except AttributeError as e:
                raise AttributeError(
                    f"Class {connector_name} not found in {connector_module_path}"
                ) from e
            connector_cls = cast(type[KVConnectorBaseType], connector_cls)
            if not supports_kw(connector_cls, "kv_cache_config"):
                msg = (
                    f"Connector {connector_cls.__name__} uses deprecated "
                    "2-argument constructor signature. External v1 KV "
                    "connectors must accept kv_cache_config as the third "
                    "constructor argument and pass it to super().__init__()."
                )
                logger.error(msg)
                raise ValueError(msg)
        elif connector_name in cls._registry:
            connector_cls = cls._registry[connector_name]()
        else:
            raise ValueError(f"Unsupported connector type: {connector_name}")
        return connector_cls
  1. Lines 106-124. When kv_connector_module_path is set, it wins over the registry. vLLM imports the module and takes the class named kv_connector. A class without the kv_cache_config constructor argument raises.
  2. Lines 125-126. Else the registry: it calls the stored loader, which imports the module only now.
  3. Lines 127-128. An unknown name raises "Unsupported connector type".

A plugin registers a name with register_connector(name, module_path, class_name). A name that exists raises ValueError (vllm/distributed/kv_transfer/kv_connector/factory.py:30-40). The built-in names are registered when the factory module is imported (lines 149-249). The page distributed/kv_transfer/kv_connector/factory.py: the KV connector registry reads the file line by line.

Step 3: one class, two objects#

The scheduler makes the SCHEDULER object in its constructor:

        kv_transfer_config = self.vllm_config.kv_transfer_config
        if kv_transfer_config is not None:
            assert not self.is_encoder_decoder, (
                "Encoder-decoder models are not currently supported with KV connectors"
            )
            self.connector = KVConnectorFactory.create_connector(
                config=self.vllm_config,
                role=KVConnectorRole.SCHEDULER,
                kv_cache_config=self.kv_cache_config,
            )
            if self.log_stats:
                self.connector_prefix_cache_stats = PrefixCacheStats()
            kv_load_failure_policy = kv_transfer_config.kv_load_failure_policy
            self.recompute_kv_load_failures = kv_load_failure_policy == "recompute"

            # With overlapping batches (async scheduling or PP), a step may
            # still be writing a freed request's KV blocks. A consumer KV
            # Connector can reallocate and fill those blocks via a load that
            # isn't ordered against that write, so defer freeing them.
            multiple_inflight_batches = self.vllm_config.max_concurrent_batches > 1
            if multiple_inflight_batches and kv_transfer_config.is_kv_consumer:
                self.defer_block_free = True

            self.requires_kv_delivery = self.connector.requires_kv_delivery

Each worker makes the WORKER object after it knows the KV cache config (vllm/v1/worker/gpu_worker.py:873-878, vllm/distributed/kv_transfer/kv_transfer_state.py:73-92). The roles are an enum with two values (vllm/distributed/kv_transfer/kv_connector/v1/base.py:139-144). The factory checks the hybrid KV cache manager: a connector that does not support it raises when the manager is on (vllm/distributed/kv_transfer/kv_connector/factory.py:54-60). VllmConfig turns the manager off for such a connector when the user did not choose, with a warning (vllm/config/vllm.py:2236-2259).

The base class constructor stores the config and logs a warning that the API "is experimental and subject to change" (vllm/distributed/kv_transfer/kv_connector/v1/base.py:214-232).

Step 4: the scheduler-side methods#

Three methods are abstract on the scheduler side (vllm/distributed/kv_transfer/kv_connector/v1/base.py:515-611):

MethodCalledReturns, and what the scheduler does
get_num_new_matched_tokens(request, num_computed_tokens)once for a waiting request with no computed tokens, after the local prefix lookup (vllm/v1/core/sched/scheduler.py:1027-1039)(n, async): n more tokens can be loaded. None means "ask again": the request is skipped this step (vllm/v1/core/sched/scheduler.py:1043-1048).
update_state_after_alloc(request, blocks, num_external_tokens)after allocate_slots succeeds (vllm/v1/core/sched/scheduler.py:1352-1357)nothing; the connector notes which blocks to fill
build_connector_meta(scheduler_output)at the end of schedule() (vllm/v1/core/sched/scheduler.py:1615-1617)the metadata object that goes to the workers inside SchedulerOutput; the call also resets the connector state

Other scheduler-side methods have defaults: request_finished (no delay), update_connector_output, on_new_request, take_events and more (vllm/distributed/kv_transfer/kv_connector/v1/base.py:613-680).

Step 5: the worker-side methods#

Four methods are abstract on the worker side (vllm/distributed/kv_transfer/kv_connector/v1/base.py:332-396): start_load_kv, wait_for_layer_load, save_kv_layer and wait_for_save. Model Runner V2 wraps them in a small class:

    def pre_forward(
        self,
        scheduler_output: "SchedulerOutput",
        input_batch: "InputBatch | None" = None,
        attn_metadata: dict[str, Any] | None = None,
    ) -> None:
        if self._disabled:
            return

        kv_connector_metadata = scheduler_output.kv_connector_metadata
        assert kv_connector_metadata is not None
        self.kv_connector.handle_preemptions(kv_connector_metadata)
        self.kv_connector.bind_connector_metadata(kv_connector_metadata)
        self._pending_load_kwargs = self._build_load_kwargs(
            scheduler_output, input_batch, attn_metadata
        )

        if scheduler_output.has_sync_kv_loads:
            # Sync loads need to run before this step's forward.
            self._start_load_kv()
        # Otherwise start the async load after forward to keep submission
        # off the critical path.

A synchronous load must finish before the forward reads the blocks, so it starts in pre_forward. An asynchronous load starts after the forward, "to keep submission off the critical path". The layer-by-layer calls wrap the attention custom op:

    @wraps(func)
    def wrapper(*args, **kwargs):
        if not has_kv_transfer_group() or not is_v1_kv_transfer_group():
            return func(*args, **kwargs)

        layer_name = _resolve_layer_name(args[layer_name_index])

        # Extract attention context (metadata, layer, kv_cache, layer_slot_mapping)
        attn_metadata, _, kv_cache, _ = get_attention_context(layer_name)
        connector = get_kv_transfer_group()
        if attn_metadata is None or not connector.has_connector_metadata():
            return func(*args, **kwargs)

        # Wait for KV layer on entry
        connector.wait_for_layer_load(layer_name)

        # Execute the function
        result = func(*args, **kwargs)

        # Save KV cache layer on exit
        connector.save_kv_layer(layer_name, kv_cache, attn_metadata)

        return result

    return wrapper

After the forward, post_forward calls wait_for_save and collects the finished transfers into a KVConnectorOutput (vllm/v1/worker/gpu/kv_connector.py:136-160). With speculative decoding, the save finishes later, after the drafter runs (vllm/v1/worker/gpu/model_runner.py:2220-2224). The model runner calls it on every pipeline rank (vllm/v1/worker/gpu/model_runner.py:2150, vllm/v1/worker/gpu/model_runner.py:2222-2224).

Step 6: async loads, delayed caching and deferred frees#

The scheduler integrates the connector in four places:

  1. External prefix hits. The connector sees a block-aligned local hit and returns more tokens. The scheduler counts them as computed (vllm/v1/core/sched/scheduler.py:1027-1039).
  2. Async loads. For an async load, allocate_slots gets delay_cache_blocks=True (vllm/v1/core/sched/scheduler.py:1327). The block hashes are not committed yet, because the bytes are not there (vllm/v1/core/kv_cache_manager.py:602-605). The request goes to WAITING_FOR_REMOTE_KVS and is skipped for this step (vllm/v1/core/sched/scheduler.py:1384-1411).
  3. Completion. When a worker reports finished_recving, the request is marked ready (vllm/v1/core/sched/scheduler.py:3325-3334). The next schedule caches the loaded blocks and moves it back to WAITING (vllm/v1/core/sched/scheduler.py:3275-3289, vllm/v1/core/sched/scheduler.py:3254-3257).
  4. Frees. request_finished can return True (a connector with SupportsHMA gets request_finished_all_groups, vllm/v1/core/sched/scheduler.py:3145-3157): the blocks stay until the worker reports finished_sending (vllm/v1/core/sched/scheduler.py:2840-2842, vllm/v1/core/sched/scheduler.py:3335-3338). Separately, defer_block_free holds freed blocks while an in-flight step may still write them. It is on only with more than one batch in flight and a consumer role (vllm/v1/core/sched/scheduler.py:187-193).

Step 7: which processes read the registry#

The registry is a class attribute in each process. Three processes read it, for different reasons:

ProcessReadsWhere
front endthe class only, for config checks: the hybrid manager, sleep mode, the CUDA graph mode, and the metrics loggersvllm/config/vllm.py:2243, vllm/config/vllm.py:1242, vllm/config/vllm.py:1966, vllm/v1/metrics/loggers.py:132 → vllm/distributed/kv_transfer/kv_connector/v1/metrics.py:57, vllm/distributed/kv_transfer/kv_connector/v1/metrics.py:161
engine coremakes the SCHEDULER objectvllm/v1/core/sched/scheduler.py:177-181
each workermakes the WORKER objectvllm/distributed/kv_transfer/kv_transfer_state.py:88-92

So a connector registered by a plugin must be registered in all three processes. A general plugin does that. kv_connector_module_path needs no registration, because each process imports the module itself.

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 dive

The 17 built-in connectors

Registered at vllm/distributed/kv_transfer/kv_connector/factory.py:149-249. The descriptions come from the docstrings and comments of each module.

NameWhat it doesSource
ExampleConnector"Simple debug implementation"; saves and loads KV to and from disk (shared_storage_path)vllm/distributed/kv_transfer/kv_connector/v1/example_connector.py:85-88
ExampleHiddenStatesConnectordebug connector that stores hidden states to disk, for the extract_hidden_states methodvllm/distributed/kv_transfer/kv_connector/v1/example_hidden_states_connector.py:99-104
LMCacheConnectorV1, LMCacheMPConnectorthe external LMCache library; the MP form talks to a separate lmcache serverdocs/features/disagg_prefill.md:23
NixlConnector, NixlPullConnector, NixlPushConnectorNIXL transfer for disaggregated prefill; pull (READ) or push (WRITE); NixlConnector is an alias of the pull formvllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py:350-351, vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py:391-392, vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py:432
MultiConnectorwraps several connectors: loads from the first that reports tokens, saves to allvllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py:133-140
HiSparseConnectorconnector boundary for HiSparse sparse-MLA host offloadingvllm/distributed/kv_transfer/kv_connector/v1/hisparse/connector.py:3, vllm/config/attention.py:122-126
MoRIIOConnectorMoRI-IO transfer, ROCm onlydocs/features/disagg_prefill.md:31
OffloadingConnectorvLLM's native offloading to CPU (and more tiers) through vllm/v1/kv_offloadvllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py:79-100
DecodeBenchConnectorfills the KV cache with dummy values to test a decode instancevllm/distributed/kv_transfer/kv_connector/v1/decode_bench_connector.py:3-7
MooncakeConnectordirect peer-to-peer transfer with Mooncakevllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py:6-10
MooncakeStoreConnectora shared KV pool in MooncakeDistributedStorevllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py:6-10
FlexKVConnectorV1offload to FlexKV: CPU memory, SSD and remote storagevllm/distributed/kv_transfer/kv_connector/v1/flexkv_connector.py:36-40
SimpleCPUOffloadConnector"minimal CPU KV cache offloading"vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py:3
HF3FSKVConnectorstores KV in the 3FS file systemvllm/distributed/kv_transfer/kv_connector/v1/hf3fs/hf3fs_connector.py:3-6

There is no P2pNcclConnector at this commit (git grep finds no such name in vllm/). The docs say "Now supports 9 types of connectors" and name nine in eight bullets (docs/features/disagg_prefill.md:20-49). LMCacheMPConnector is inside the LMCache bullet. The registry has 17 names.

Deep dive

The metadata contract between the two roles

Deep dive

Worked example: an async load of a 3,000-token prompt

A consumer instance with block_size 16 gets a request with a 3,000-token prompt. The local prefix cache has the first 62 full blocks, 992 tokens; only full blocks can hit (vllm/v1/core/kv_cache_manager.py:264-266). The connector can load 1,984 more tokens asynchronously. (The numbers are an example; the steps follow the cited lines.)

  1. The local hit is 992 tokens. 992 mod 16 = 0, so the connector sees all 992 tokens (vllm/v1/core/sched/scheduler.py:1031-1034).
  2. get_num_new_matched_tokens(request, 992) returns (1984, True). There is no partial tail, so all 1,984 tokens are external (vllm/v1/core/sched/scheduler.py:1083-1084). (A local hit that ends inside a block loses its partial block when the remote hit is longer, lines 1064-1075.)
  3. allocate_slots gets 992 local and 1,984 external computed tokens, and delay_cache_blocks=True (vllm/v1/core/sched/scheduler.py:1320-1337). 2,976 tokens fill 186 blocks of 16. update_state_after_alloc tells the connector the block ids.
  4. The request goes to WAITING_FOR_REMOTE_KVS, with num_computed_tokens set already (vllm/v1/core/sched/scheduler.py:1393-1407). It runs no prefill this step.
  5. The workers start the load after their forward. Some steps later a worker reports finished_recving.
  6. The next schedule() caches the loaded blocks and puts the request back to WAITING. The remaining 24 prompt tokens are prefilled as usual.

If the whole prompt was loaded, vLLM sets num_computed_tokens to the prompt length minus one. So one token runs, and the model samples the first output token (vllm/v1/core/sched/scheduler.py:3271-3272).

Deep dive

EC connectors: the same pattern for encoder outputs

A multimodal model runs an encoder (for example a vision tower) before the language model. An EC connector moves the encoder outputs between instances, so one instance can run only the encoder. It has its own config, --ec-transfer-config, with ec_connector, ec_role (ec_producer, ec_consumer or ec_both) and ec_connector_module_path (vllm/config/ec_transfer.py:10-54).

The factory is a copy of the KV factory, with one difference: the registry wins, and the module path is only the fallback (vllm/distributed/ec_transfer/ec_connector/factory.py:58-74). Three connectors are built in: ECExampleConnector, ECCPUConnector and ECMooncakeConnector (vllm/distributed/ec_transfer/ec_connector/factory.py:81-97). The scheduler makes the scheduler-side object (vllm/v1/core/sched/scheduler.py:201-205); each worker makes the worker-side object (vllm/distributed/ec_transfer/ec_transfer_state.py:26-39). The base class has start_load_caches, save_caches, has_cache_item, update_state_after_alloc and build_connector_meta as abstract methods (vllm/distributed/ec_transfer/ec_connector/base.py:164-312). The scheduler calls request_finished on it next to the KV connector (vllm/v1/core/sched/scheduler.py:2829-2832).

Deep dive

KV offloading: vllm/v1/kv_offload

KV offloading copies blocks to CPU memory or another tier, so a later request with the same prefix can load them. The easy switch is --kv-offloading-size in GiB. VllmConfig then makes or changes the KV transfer config:

        # KV offloading is only activated when kv_offloading_size is set.
        if (kv_offloading_size := self.cache_config.kv_offloading_size) is None:
            return

        kv_offloading_backend = self.cache_config.kv_offloading_backend

        # If no KVTransferConfig is provided, create a default one.
        if self.kv_transfer_config is None:
            self.kv_transfer_config = KVTransferConfig()

        if kv_offloading_backend == "native":
            if envs.VLLM_USE_SIMPLE_KV_OFFLOAD:
                config_connector = "SimpleCPUOffloadConnector"
            else:
                config_connector = "OffloadingConnector"
            self.kv_transfer_config.kv_connector = config_connector
            self.kv_transfer_config.kv_connector_extra_config.update(
                {"cpu_bytes_to_use": kv_offloading_size * (1 << 30)}
            )
        elif kv_offloading_backend == "lmcache":
            # Default to LMCache multi-process (MP) mode. The actual KV
            # storage capacity is managed by the standalone LMCache server
            # process, so ``kv_offloading_size`` is not propagated here.
            # ``LMCacheMPConnector`` falls back to ``tcp://localhost:5555``
            # when host/port are not provided via extra_config.
            self.kv_transfer_config.kv_connector = "LMCacheMPConnector"

        # This is the same for all backends
        self.kv_transfer_config.kv_role = "kv_both"

The default backend native selects OffloadingConnector, or SimpleCPUOffloadConnector with VLLM_USE_SIMPLE_KV_OFFLOAD=1 (default 0, vllm/envs.py:2239-2241). The size becomes cpu_bytes_to_use. The docstring says that with TP > 1 the size is "the total buffer size summed across all TP ranks" (vllm/config/cache.py:380-389). Note that lines 1154 and 1167 overwrite kv_connector and kv_role of a config that you also passed. (Derived.)

The native code has its own small plugin point. OffloadingConnector builds an OffloadingSpec through OffloadingSpecFactory (vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py:87-89). The spec gives a scheduler-side OffloadingManager and a worker-side OffloadingWorker (vllm/v1/kv_offload/base.py:724-743). Two specs are built in, CPUOffloadingSpec (the default) and TieringOffloadingSpec. An out-of-tree spec is named with spec_name and spec_module_path in kv_connector_extra_config, with an "experimental" warning (vllm/v1/kv_offload/factory.py:30-69).

What can go wrong#

Why it matters. KV connectors are the base of disaggregated prefill and of KV offloading, two of the main ways to scale online serving. Most connector problems are process problems: which process registered the class, and which role runs where.

Check yourself#

  1. 1
    Which object of a KV connector runs in the engine core, and which runs in the workers?
    easy
    Show answer

    The SCHEDULER-role object runs in the engine core, made by the scheduler (vllm/v1/core/sched/scheduler.py:177-181). Each worker makes a WORKER-role object (vllm/distributed/kv_transfer/kv_transfer_state.py:88-92).

  2. 2
    What does get_num_new_matched_tokens return, and what does None mean?
    easy
    Show answer

    A pair: the number of extra tokens that can be loaded, and whether the load is asynchronous. None as the count means that the connector needs more time; the scheduler skips the request this step and asks again (vllm/distributed/kv_transfer/kv_connector/v1/base.py:529-538, vllm/v1/core/sched/scheduler.py:1043-1048).

  3. 3
    You set both kv_connector_module_path and a registry entry for the same name. Which class runs? And for an EC connector?
    medium
    Show answer

    For KV connectors the module path wins (vllm/distributed/kv_transfer/kv_connector/factory.py:106-108). For EC connectors the registry wins and the module path is the fallback (vllm/distributed/ec_transfer/ec_connector/factory.py:66-73).

  4. 4
    Why does the scheduler pass delay_cache_blocks=True for an async load?
    medium
    Show answer

    The blocks are allocated, but the bytes arrive later. Committing their hashes now would let another request hit blocks that hold no data. allocate_slots returns before cache_blocks in that case (vllm/v1/core/kv_cache_manager.py:602-605), and the blocks are cached when the load finishes (vllm/v1/core/sched/scheduler.py:3254-3257).

  5. 5
    You start with --kv-offloading-size 8 --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'. Which connector and role run?
    hard
    Show answer

    OffloadingConnector with kv_both. When kv_offloading_size is set and the backend is native, _post_init_kv_transfer_config overwrites kv_connector and sets kv_role = "kv_both" on the existing config (vllm/config/vllm.py:1149-1167). To combine both, use MultiConnector. (Derived from these lines.)