[Feature] Add first-class KV hints request envelope for programmatic KV management (#53423)
Signed-off-by: Karen Chung <karenc@nvidia.com>
Karen Chung committed
Sep 21, 2026 at 09:56 UTC
986e217158a9ed54eca8307c6bb9abac7c5b2278
30 files changed
+340
-18
rust/proto/inference.proto
+15
@@ -56,6 +56,21 @@ message GenerateRequest {
56
57
// Apply the engine's configured watermark. Defaults to true when omitted.
58
optional bool watermarking = 16;
59
+
60
+ KVHintsEnvelope kv_hints = 17;
61
+}
62
+
63
+message KVHintsEnvelope {
64
+ string protocol_version = 1;
65
+ string message_id = 2;
66
+ repeated KVHintAction actions = 3;
67
+}
68
+
69
+message KVHintAction {
70
+ string action_id = 1;
71
+ string action_type = 2;
72
+ string action_version = 3;
73
+ google.protobuf.Struct payload = 4;
74
}
75
76
message RandomSampling {
rust/src/chat/src/lib.rs
+1
@@ -194,6 +194,7 @@ impl ChatRequestProcessor {
194
add_special_tokens: request.add_special_tokens,
195
data_parallel_rank: request.data_parallel_rank,
196
session_id: request.session_id,
197
+ kv_hints: None,
198
reasoning_parser_kwargs,
199
lora_request: request.lora_request,
200
arrival_time: Some(arrival_time),
rust/src/engine-core-client/src/protocol/kv_hints.rs
new
+21
@@ -0,0 +1,21 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use std::collections::BTreeMap;
5
+
6
+use serde::{Deserialize, Serialize};
7
+
8
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9
+pub struct KvHintAction {
10
+ pub action_id: String,
11
+ pub action_type: String,
12
+ pub action_version: String,
13
+ pub payload: BTreeMap<String, serde_json::Value>,
14
+}
15
+
16
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17
+pub struct KvHintsEnvelope {
18
+ pub protocol_version: String,
19
+ pub message_id: String,
20
+ pub actions: Vec<KvHintAction>,
21
+}
rust/src/engine-core-client/src/protocol/mod.rs
+1
@@ -24,6 +24,7 @@ fn is_false(v: &bool) -> bool {
24
25
pub mod dtype;
26
pub mod handshake;
27
+pub mod kv_hints;
28
pub mod logprobs;
29
pub mod lora;
30
pub mod multimodal;
rust/src/engine-core-client/src/protocol/request.rs
+16
-1
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
8
use serde_default::DefaultFromSerde;
9
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
10
11
+use crate::protocol::kv_hints::KvHintsEnvelope;
12
use crate::protocol::multimodal::MmFeatures;
13
use crate::protocol::sampling::EngineCoreSamplingParams;
14
use crate::protocol::{OpaqueValue, lora};
@@ -127,6 +128,8 @@ pub struct EngineCoreRequest {
128
/// Stable session identity shared by related requests.
129
#[serde(default)]
130
pub session_id: Option<String>,
131
+ #[serde(default)]
132
+ pub kv_hints: Option<KvHintsEnvelope>,
133
}
134
135
impl EngineCoreRequest {
@@ -159,6 +162,7 @@ mod tests {
162
163
use super::*;
164
use crate::protocol::dtype::TensorDtype;
165
+ use crate::protocol::kv_hints::{KvHintAction, KvHintsEnvelope};
166
use crate::protocol::multimodal::{
167
MmBatchedField, MmFeatureSpec, MmField, MmFieldElem, MmKwargValue, MmModality,
168
PlaceholderRange,
@@ -181,6 +185,16 @@ mod tests {
185
arrival_time: 1234.5,
186
client_index: 7,
187
session_id: Some("session-1".to_string()),
188
+ kv_hints: Some(KvHintsEnvelope {
189
+ protocol_version: "0.1".to_string(),
190
+ message_id: "msg-1".to_string(),
191
+ actions: vec![KvHintAction {
192
+ action_id: "action-1".to_string(),
193
+ action_type: "example.action".to_string(),
194
+ action_version: "1.0".to_string(),
195
+ payload: BTreeMap::from([("key".to_string(), serde_json::json!("value"))]),
196
+ }],
197
+ }),
198
..EngineCoreRequest::default()
199
};
200
@@ -191,13 +205,14 @@ mod tests {
205
other => panic!("expected array, got {other:?}"),
206
};
207
194
- assert_eq!(array.len(), 21);
208
+ assert_eq!(array.len(), 22);
209
assert_eq!(array[0], Value::from("req-1"));
210
assert_eq!(array[2], Value::Nil);
211
assert_eq!(array[4], Value::Nil);
212
assert_eq!(array[10], Value::Nil);
213
assert_eq!(array[11], Value::from(7));
214
assert_eq!(array[20], Value::from("session-1"));
215
+ assert!(matches!(&array[21], Value::Map(_)));
216
}
217
218
#[test]
rust/src/engine-core-client/src/tests/client.rs
+11
@@ -20,6 +20,7 @@ use zeromq::util::PeerIdentity;
20
use zeromq::{DealerSocket, PushSocket, SocketOptions, SubSocket, XPubSocket, ZmqMessage};
21
22
use crate::protocol::handshake::{EngineCoreReadyResponse, HandshakeInitMessage, ReadyMessage};
23
+use crate::protocol::kv_hints::{KvHintAction, KvHintsEnvelope};
24
use crate::protocol::logprobs::MaybeWireLogprobs;
25
use crate::protocol::multimodal::{
26
MmFeatureSpec, MmField, MmFieldElem, MmFlatField, MmKwargValue, MmModality, MmSlice,
@@ -165,6 +166,16 @@ fn sample_request_with_id(request_id: &str) -> EngineCoreRequest {
166
}),
167
arrival_time: 42.5,
168
session_id: Some("session-1".to_string()),
169
+ kv_hints: Some(KvHintsEnvelope {
170
+ protocol_version: "0.1".to_string(),
171
+ message_id: "msg-1".to_string(),
172
+ actions: vec![KvHintAction {
173
+ action_id: "action-1".to_string(),
174
+ action_type: "example.action".to_string(),
175
+ action_version: "1.0".to_string(),
176
+ payload: BTreeMap::from([("key".to_string(), serde_json::json!("value"))]),
177
+ }],
178
+ }),
179
..EngineCoreRequest::default()
180
}
181
}
rust/src/engine-core-client/src/tests/python_compat.py
+26
@@ -52,6 +52,19 @@ class EngineCoreSamplingParams(msgspec.Struct, dict=True, omit_defaults=True):
52
output_kind: RequestOutputKind = RequestOutputKind.DELTA
53
54
55
+class KvHintAction(msgspec.Struct, frozen=True):
56
+ action_id: str
57
+ action_type: str
58
+ action_version: str
59
+ payload: dict[str, object]
60
+
61
+
62
+class KvHintsEnvelope(msgspec.Struct, frozen=True):
63
+ protocol_version: str
64
+ message_id: str
65
+ actions: list[KvHintAction]
66
+
67
+
68
class EngineCoreRequest(
69
msgspec.Struct,
70
array_like=True,
@@ -78,6 +91,7 @@ class EngineCoreRequest(
91
reasoning_parser_kwargs: dict[str, object] | None = None
92
abort_immediately: bool = False
93
session_id: str | None = None
94
+ kv_hints: KvHintsEnvelope | None = None
95
96
97
class EngineCoreOutput(
@@ -153,6 +167,18 @@ request = EngineCoreRequest(
167
arrival_time=42.5,
168
client_index=0,
169
session_id="session-1",
170
+ kv_hints=KvHintsEnvelope(
171
+ protocol_version="0.1",
172
+ message_id="msg-1",
173
+ actions=[
174
+ KvHintAction(
175
+ action_id="action-1",
176
+ action_type="example.action",
177
+ action_version="1.0",
178
+ payload={"key": "value"},
179
+ )
180
+ ],
181
+ ),
182
)
183
184
# All defaults -> empty map. Regression guard for the sparse-map decode.
rust/src/llm/examples/external_engine_smoke.rs
+1
@@ -60,6 +60,7 @@ fn build_request(request_id: String, max_tokens: u32) -> GenerateRequest {
60
priority: 0,
61
data_parallel_rank: None,
62
session_id: None,
63
+ kv_hints: None,
64
reasoning_parser_kwargs: None,
65
lora_request: None,
66
}
rust/src/llm/src/request.rs
+20
@@ -4,6 +4,7 @@
4
use std::collections::BTreeMap;
5
6
use uuid::Uuid;
7
+use vllm_engine_core_client::protocol::kv_hints::KvHintsEnvelope;
8
use vllm_engine_core_client::protocol::lora::LoraRequest;
9
use vllm_engine_core_client::protocol::multimodal::MmFeatures;
10
use vllm_engine_core_client::protocol::request::{EngineCoreRequest, ReasoningParserKwargs};
@@ -48,6 +49,8 @@ pub struct GenerateRequest {
49
pub data_parallel_rank: Option<u32>,
50
/// Stable session identity shared by related requests.
51
pub session_id: Option<String>,
52
+ /// Optional orchestrator-originated KV hints.
53
+ pub kv_hints: Option<KvHintsEnvelope>,
54
/// Optional reasoning-parser kwargs forwarded to engine-side structured
55
/// output logic.
56
pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
@@ -79,6 +82,7 @@ impl GenerateRequest {
82
priority,
83
data_parallel_rank,
84
session_id,
85
+ kv_hints,
86
reasoning_parser_kwargs,
87
lora_request,
88
} = self;
@@ -109,6 +113,7 @@ impl GenerateRequest {
113
trace_headers,
114
resumable: false,
115
session_id,
116
+ kv_hints,
117
external_req_id: Some(external_request_id),
118
// Rust parser doesn't expose this information, leave it unset and let the
119
// reasoning logic in engine-sided structured output manager handle it.
@@ -134,6 +139,7 @@ impl PreparedGenerateRequest {
139
mod tests {
140
use std::collections::BTreeMap;
141
142
+ use vllm_engine_core_client::protocol::kv_hints::{KvHintAction, KvHintsEnvelope};
143
use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
144
use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams;
145
@@ -155,6 +161,16 @@ mod tests {
161
priority: 3,
162
data_parallel_rank: Some(2),
163
session_id: Some("session-1".to_string()),
164
+ kv_hints: Some(KvHintsEnvelope {
165
+ protocol_version: "0.1".to_string(),
166
+ message_id: "msg-1".to_string(),
167
+ actions: vec![KvHintAction {
168
+ action_id: "action-1".to_string(),
169
+ action_type: "example.action".to_string(),
170
+ action_version: "1.0".to_string(),
171
+ payload: BTreeMap::new(),
172
+ }],
173
+ }),
174
reasoning_parser_kwargs: Some(ReasoningParserKwargs {
175
chat_template_kwargs: [(
176
"chat_template_kwargs".to_string(),
@@ -183,6 +199,10 @@ mod tests {
199
assert_eq!(request.cache_salt.as_deref(), Some("salt"));
200
assert_eq!(request.data_parallel_rank, Some(2));
201
assert_eq!(request.session_id.as_deref(), Some("session-1"));
202
+ assert_eq!(
203
+ request.kv_hints.as_ref().map(|hints| hints.message_id.as_str()),
204
+ Some("msg-1")
205
+ );
206
assert_eq!(
207
request.trace_headers,
208
Some(BTreeMap::from([(
rust/src/llm/tests/generate.rs
+1
@@ -167,6 +167,7 @@ fn sample_generate_request(request_id: &str, max_tokens: u32) -> GenerateRequest
167
priority: 0,
168
data_parallel_rank: None,
169
session_id: None,
170
+ kv_hints: None,
171
reasoning_parser_kwargs: None,
172
lora_request: None,
173
}
rust/src/server/src/grpc/convert.rs
+61
-1
@@ -7,6 +7,7 @@
7
use thiserror_ext::AsReport as _;
8
use tonic::Status;
9
use uuid::Uuid;
10
+use vllm_engine_core_client::protocol::kv_hints::{KvHintAction, KvHintsEnvelope};
11
use vllm_engine_core_client::protocol::output::StopReason;
12
use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams;
13
use vllm_text::{
@@ -16,6 +17,32 @@ use vllm_text::{
17
18
use super::pb;
19
20
+fn kv_hints_from_proto(hints: pb::KvHintsEnvelope) -> KvHintsEnvelope {
21
+ KvHintsEnvelope {
22
+ protocol_version: hints.protocol_version,
23
+ message_id: hints.message_id,
24
+ actions: hints
25
+ .actions
26
+ .into_iter()
27
+ .map(|action| KvHintAction {
28
+ action_id: action.action_id,
29
+ action_type: action.action_type,
30
+ action_version: action.action_version,
31
+ payload: action
32
+ .payload
33
+ .map(|payload| {
34
+ payload
35
+ .fields
36
+ .into_iter()
37
+ .map(|(key, value)| (key, proto_value_to_json(&value)))
38
+ .collect()
39
+ })
40
+ .unwrap_or_default(),
41
+ })
42
+ .collect(),
43
+ }
44
+}
45
+
46
// ========================================================================================
47
// Request conversion
48
// ========================================================================================
@@ -56,6 +83,7 @@ pub fn to_text_request(
83
req.request_id
84
};
85
let session_id = req.session_id.filter(|s| !s.is_empty());
86
+ let kv_hints = req.kv_hints.map(kv_hints_from_proto);
87
88
let sampling = req.sampling.as_ref();
89
let decoding = req.decoding.as_ref();
@@ -108,6 +136,7 @@ pub fn to_text_request(
136
add_special_tokens: true,
137
data_parallel_rank: None,
138
session_id,
139
+ kv_hints,
140
reasoning_parser_kwargs: None,
141
lora_request: None,
142
arrival_time: None,
@@ -537,7 +566,9 @@ mod tests {
566
use vllm_tokenizer::test_utils::TestTokenizer;
567
568
use super::pb::finish_info::{FinishReason as PbFinishReason, StopReason as PbStopReason};
540
- use super::{ResponseOpts, pb, to_finish_info, to_sequence_output, to_text_request};
569
+ use super::{
570
+ ResponseOpts, json_to_proto_struct, pb, to_finish_info, to_sequence_output, to_text_request,
571
+ };
572
573
fn base_request() -> pb::GenerateRequest {
574
pb::GenerateRequest {
@@ -578,6 +609,35 @@ mod tests {
609
assert_eq!(text.sampling_params.temperature, Some(0.7));
610
}
611
612
+ #[test]
613
+ fn kv_hints_propagate_from_top_level_request_field() {
614
+ let req = pb::GenerateRequest {
615
+ kv_hints: Some(pb::KvHintsEnvelope {
616
+ protocol_version: "0.1".to_string(),
617
+ message_id: "msg-1".to_string(),
618
+ actions: vec![pb::KvHintAction {
619
+ action_id: "action-1".to_string(),
620
+ action_type: "example.action".to_string(),
621
+ action_version: "1.0".to_string(),
622
+ payload: json_to_proto_struct(&serde_json::json!({"source": "g2"})),
623
+ }],
624
+ }),
625
+ ..base_request()
626
+ };
627
+
628
+ let text = to_text_request(req, false, &["test-model".to_string()]).expect("convert ok");
629
+ let hints = text.kv_hints.expect("kv hints");
630
+ assert_eq!(hints.protocol_version, "0.1");
631
+ assert_eq!(hints.message_id, "msg-1");
632
+ assert_eq!(hints.actions[0].action_id, "action-1");
633
+ assert_eq!(hints.actions[0].action_type, "example.action");
634
+ assert_eq!(hints.actions[0].action_version, "1.0");
635
+ assert_eq!(
636
+ hints.actions[0].payload.get("source"),
637
+ Some(&serde_json::json!("g2"))
638
+ );
639
+ }
640
+
641
#[test]
642
fn unset_temperature_defaults_to_greedy() {
643
let text = to_text_request(base_request(), false, &["test-model".to_string()])
rust/src/server/src/routes/inference/generate/convert.rs
+1
@@ -82,6 +82,7 @@ pub(super) fn prepare_generate_request(
82
add_special_tokens: false,
83
data_parallel_rank: ctx.data_parallel_rank,
84
session_id: ctx.session_id,
85
+ kv_hints: None,
86
reasoning_parser_kwargs: None,
87
lora_request: lora_resolution.lora_request.clone(),
88
arrival_time: None,
rust/src/server/src/routes/openai/completions/convert.rs
+1
@@ -155,6 +155,7 @@ pub(super) fn prepare_completion_request(
155
add_special_tokens: request.add_special_tokens,
156
data_parallel_rank: ctx.data_parallel_rank,
157
session_id,
158
+ kv_hints: None,
159
reasoning_parser_kwargs: None,
160
lora_request: lora_resolution.lora_request.clone(),
161
arrival_time: None,
rust/src/server/src/routes/tokenize/types.rs
+1
@@ -54,6 +54,7 @@ impl TokenizeCompletionRequest {
54
add_special_tokens: self.add_special_tokens,
55
data_parallel_rank: None,
56
session_id: None,
57
+ kv_hints: None,
58
reasoning_parser_kwargs: None,
59
lora_request: None,
60
arrival_time: None,
rust/src/text/src/lower.rs
+31
@@ -60,6 +60,7 @@ pub fn lower_text_request(
60
priority: request.priority,
61
data_parallel_rank: request.data_parallel_rank,
62
session_id: request.session_id.clone(),
63
+ kv_hints: request.kv_hints.clone(),
64
reasoning_parser_kwargs: request.reasoning_parser_kwargs.clone(),
65
lora_request: request.lora_request.clone(),
66
arrival_time: request.arrival_time,
@@ -320,6 +321,7 @@ mod tests {
321
use std::collections::{BTreeSet, HashMap};
322
323
use serial_test::file_serial;
324
+ use vllm_engine_core_client::protocol::kv_hints::{KvHintAction, KvHintsEnvelope};
325
use vllm_engine_core_client::protocol::multimodal::{
326
MmFeatureSpec, MmModality, PlaceholderRange,
327
};
@@ -1301,6 +1303,35 @@ mod tests {
1303
assert_eq!(prepared.generate_request.request_id, "text-1");
1304
}
1305
1306
+ #[test]
1307
+ fn lower_text_request_passes_kv_hints_through() {
1308
+ let hints = KvHintsEnvelope {
1309
+ protocol_version: "0.1".to_string(),
1310
+ message_id: "msg-1".to_string(),
1311
+ actions: vec![KvHintAction {
1312
+ action_id: "action-1".to_string(),
1313
+ action_type: "example.action".to_string(),
1314
+ action_version: "1.0".to_string(),
1315
+ payload: Default::default(),
1316
+ }],
1317
+ };
1318
+ let request = TextRequest {
1319
+ kv_hints: Some(hints.clone()),
1320
+ ..sample_request()
1321
+ };
1322
+
1323
+ let prepared = lower_text_request(
1324
+ request,
1325
+ vec![1, 2, 3],
1326
+ sample_sampling_hints(),
1327
+ sample_sampling_limits(),
1328
+ &stub_tokenizer(),
1329
+ )
1330
+ .unwrap();
1331
+
1332
+ assert_eq!(prepared.generate_request.kv_hints, Some(hints));
1333
+ }
1334
+
1335
#[test]
1336
fn lower_text_request_passes_arrival_time_through() {
1337
let request = TextRequest {
rust/src/text/src/request.rs
+5
@@ -6,6 +6,7 @@ use std::collections::HashMap;
6
use enum_as_inner::EnumAsInner;
7
use serde::{Deserialize, Serialize};
8
use serde_json::Value;
9
+use vllm_engine_core_client::protocol::kv_hints::KvHintsEnvelope;
10
use vllm_engine_core_client::protocol::lora::LoraRequest;
11
use vllm_engine_core_client::protocol::multimodal::MmFeatures;
12
use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
@@ -211,6 +212,9 @@ pub struct TextRequest {
212
/// Stable session identity shared by related requests.
213
#[serde(default)]
214
pub session_id: Option<String>,
215
+ /// Optional orchestrator-originated KV hints.
216
+ #[serde(default)]
217
+ pub kv_hints: Option<KvHintsEnvelope>,
218
/// Optional reasoning-parser kwargs forwarded to engine-side structured
219
/// output logic.
220
#[serde(default)]
@@ -242,6 +246,7 @@ impl TextRequest {
246
add_special_tokens: false,
247
data_parallel_rank: None,
248
session_id: None,
249
+ kv_hints: None,
250
reasoning_parser_kwargs: None,
251
lora_request: None,
252
arrival_time: None,
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+18
@@ -33,6 +33,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
33
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
34
OffloadingConnectorScheduler,
35
RequestOffloadState,
36
+ _create_req_context,
37
get_sliding_window_size_in_chunks,
38
)
39
from vllm.v1.core.block_pool import BlockPool
@@ -60,6 +61,7 @@ from vllm.v1.kv_cache_interface import (
61
SlidingWindowSpec,
62
)
63
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
64
+from vllm.v1.kv_hints import KvHintsEnvelope
65
from vllm.v1.kv_offload.base import (
66
GPULoadStoreSpec,
67
LookupResult,
@@ -214,6 +216,7 @@ def _make_partial_tail_request(
216
request = MagicMock()
217
request.request_id = "req"
218
request.kv_transfer_params = kv_transfer_params
219
+ request.kv_hints = None
220
request.num_prompt_tokens = 30
221
request.num_tokens = 30
222
request.block_hashes = [BlockHash(f"h{i}".encode()) for i in range(7)]
@@ -225,6 +228,21 @@ def _make_partial_tail_request(
228
return request
229
230
231
+def test_create_req_context_preserves_kv_hints():
232
+ kv_hints = KvHintsEnvelope(
233
+ protocol_version="0.1",
234
+ message_id="message-1",
235
+ actions=[],
236
+ )
237
+ request = MagicMock(
238
+ request_id="req",
239
+ kv_transfer_params=None,
240
+ kv_hints=kv_hints,
241
+ )
242
+
243
+ assert _create_req_context(request).kv_hints is kv_hints
244
+
245
+
246
def _reduce_kv_connector_stats(runner):
247
reduced: dict[str, int | float] = {}
248
for payload in runner.kv_connector_stats:
tests/v1/kv_offload/tiering/test_kvcr_tier.py
+8
-4
@@ -5,12 +5,13 @@ from collections.abc import Collection, Iterable, Mapping
5
from types import SimpleNamespace
6
from typing import Any
7
8
+import msgspec
9
import numpy as np
10
import pytest
11
12
pytest.importorskip("kvcr")
13
13
-from kvcr import ROUTER_HINT_KEY, KVCRBindings
14
+from kvcr import KVCRBindings
15
from kvcr.config import G3Options, KVCRBackendConfigs, KVCRConfig, KVCRGuardConfig
16
from kvcr.policy import FIFOPolicy, G3FIFOPolicy, G3LRUPolicy, LRUPolicy
17
from kvcr.types import (
@@ -27,6 +28,7 @@ from kvcr.types import (
28
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
29
OffloadingConnectorStats,
30
)
31
+from vllm.v1.kv_hints import KvHintsEnvelope
32
from vllm.v1.kv_offload.base import (
33
LookupResult,
34
Medium,
@@ -271,10 +273,10 @@ def test_kvcr_tier_adapts_request_and_load(monkeypatch):
273
"""Check hint forwarding, key conversion, load descriptors, and cleanup."""
274
kvcr = RecordingKVCR()
275
tier = _make_tier(monkeypatch, kvcr)
274
- router_hint = {"opaque": True}
276
+ kv_hint = KvHintsEnvelope(protocol_version="0.1", message_id="msg", actions=[])
277
ctx = ReqContext(
278
req_id="req",
277
- kv_transfer_params={ROUTER_HINT_KEY: router_hint, "unrelated": object()},
279
+ kv_hints=kv_hint,
280
)
281
key = make_offload_key((123).to_bytes(8, "big"), 0)
282
same_hash_other_group = make_offload_key((123).to_bytes(8, "big"), 7)
@@ -282,7 +284,9 @@ def test_kvcr_tier_adapts_request_and_load(monkeypatch):
284
285
tier.on_new_request(ctx)
286
285
- assert kvcr.submit_hint_calls == [((), {"request_id": "req", "hints": router_hint})]
287
+ assert kvcr.submit_hint_calls == [
288
+ ((), {"request_id": "req", "hints": msgspec.to_builtins(kv_hint)})
289
+ ]
290
291
bindings = kvcr.constructor_bindings
292
assert bindings is not None
tests/v1/test_request.py
+34
@@ -3,6 +3,7 @@
3
4
from vllm import SamplingParams
5
from vllm.v1.engine import EngineCoreRequest
6
+from vllm.v1.kv_hints import KvHintAction, KvHintsEnvelope
7
from vllm.v1.request import Request, RequestStatus
8
9
@@ -24,6 +25,7 @@ def test_request_status_fmt_str():
25
26
27
def test_request_copies_session_id_from_engine_core_request():
28
+ """Test that Request preserves session ID from EngineCoreRequest."""
29
engine_request = EngineCoreRequest(
30
request_id="request-1",
31
prompt_token_ids=[1, 2, 3],
@@ -40,3 +42,35 @@ def test_request_copies_session_id_from_engine_core_request():
42
request = Request.from_engine_core_request(engine_request, block_hasher=None)
43
44
assert request.session_id == "session-1"
45
+
46
+
47
+def test_request_copies_kv_hints_from_engine_core_request():
48
+ """Test that Request preserves KV hints from EngineCoreRequest."""
49
+ kv_hints = KvHintsEnvelope(
50
+ protocol_version="0.1",
51
+ message_id="msg-1",
52
+ actions=[
53
+ KvHintAction(
54
+ action_id="action-1",
55
+ action_type="example.action",
56
+ action_version="1.0",
57
+ payload={},
58
+ )
59
+ ],
60
+ )
61
+ engine_request = EngineCoreRequest(
62
+ request_id="request-1",
63
+ prompt_token_ids=[1, 2, 3],
64
+ mm_features=None,
65
+ sampling_params=SamplingParams(max_tokens=1),
66
+ pooling_params=None,
67
+ arrival_time=0.0,
68
+ lora_request=None,
69
+ cache_salt=None,
70
+ data_parallel_rank=None,
71
+ kv_hints=kv_hints,
72
+ )
73
+
74
+ request = Request.from_engine_core_request(engine_request, block_hasher=None)
75
+
76
+ assert request.kv_hints == kv_hints
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+1
@@ -553,6 +553,7 @@ def _create_req_context(req: Request) -> ReqContext:
553
return ReqContext(
554
req_id=req.request_id,
555
kv_transfer_params=params,
556
+ kv_hints=req.kv_hints,
557
load_tier_filter=load_filter,
558
)
559
vllm/engine/protocol.py
+2
@@ -22,6 +22,7 @@ from vllm.tasks import SupportedTask
22
from vllm.v1.engine import EngineCoreRequest
23
from vllm.v1.engine.input_processor import InputProcessor
24
from vllm.v1.fault_tolerance.utils import FaultToleranceRequest, FaultToleranceResult
25
+from vllm.v1.kv_hints import KvHintsEnvelope
26
27
if TYPE_CHECKING:
28
from vllm.v1.engine import PauseMode
@@ -102,6 +103,7 @@ class EngineClient(ABC):
103
priority: int = 0,
104
data_parallel_rank: int | None = None,
105
session_id: str | None = None,
106
+ kv_hints: KvHintsEnvelope | None = None,
107
reasoning_ended: bool | None = None,
108
reasoning_parser_kwargs: dict[str, Any] | None = None,
109
) -> AsyncGenerator[RequestOutput, None]:
vllm/v1/engine/__init__.py
+2
@@ -16,6 +16,7 @@ from vllm.lora.request import LoRARequest
16
from vllm.multimodal.inputs import MultiModalFeatureSpec
17
from vllm.pooling_params import PoolingParams
18
from vllm.sampling_params import SamplingParams
19
+from vllm.v1.kv_hints import KvHintsEnvelope
20
from vllm.v1.metrics.stats import (
21
PrefillStats,
22
RequestSpecDecodeMetrics,
@@ -157,6 +158,7 @@ class EngineCoreRequest(
158
abort_immediately: bool = False
159
160
session_id: str | None = None
161
+ kv_hints: KvHintsEnvelope | None = None
162
163
@property
164
def params(self) -> SamplingParams | PoolingParams:
vllm/v1/engine/async_llm.py
+9
@@ -53,6 +53,7 @@ from vllm.v1.engine.output_processor import OutputProcessor, RequestOutputCollec
53
from vllm.v1.engine.parallel_sampling import ParentRequest
54
from vllm.v1.executor import Executor
55
from vllm.v1.fault_tolerance.utils import FaultToleranceRequest, FaultToleranceResult
56
+from vllm.v1.kv_hints import KvHintsEnvelope
57
from vllm.v1.metrics.loggers import (
58
StatLoggerFactory,
59
StatLoggerManager,
@@ -386,6 +387,7 @@ class AsyncLLM(EngineClient):
387
prompt_text: str | None = None,
388
reasoning_ended: bool | None = None,
389
reasoning_parser_kwargs: dict[str, Any] | None = None,
390
+ kv_hints: KvHintsEnvelope | None = None,
391
) -> RequestOutputCollector:
392
"""Add new request to the AsyncLLM."""
393
if self.errored:
@@ -420,6 +422,7 @@ class AsyncLLM(EngineClient):
422
priority,
423
data_parallel_rank,
424
session_id,
425
+ kv_hints,
426
)
427
428
# Convert Input --> Request.
@@ -453,6 +456,7 @@ class AsyncLLM(EngineClient):
456
priority=priority,
457
data_parallel_rank=data_parallel_rank,
458
session_id=session_id,
459
+ kv_hints=kv_hints,
460
)
461
else:
462
# Raw prompts require tokenization and possibly multimodal
@@ -469,6 +473,7 @@ class AsyncLLM(EngineClient):
473
priority=priority,
474
data_parallel_rank=data_parallel_rank,
475
session_id=session_id,
476
+ kv_hints=kv_hints,
477
)
478
prompt_text, _, _ = extract_prompt_components(self.model_config, prompt)
479
@@ -557,6 +562,7 @@ class AsyncLLM(EngineClient):
562
priority: int = 0,
563
data_parallel_rank: int | None = None,
564
session_id: str | None = None,
565
+ kv_hints: KvHintsEnvelope | None = None,
566
) -> RequestOutputCollector:
567
self._validate_streaming_input_sampling_params(sampling_params)
568
@@ -569,6 +575,7 @@ class AsyncLLM(EngineClient):
575
priority=priority,
576
data_parallel_rank=data_parallel_rank,
577
session_id=session_id,
578
+ kv_hints=kv_hints,
579
)
580
581
if not sampling_params.skip_clone:
@@ -670,6 +677,7 @@ class AsyncLLM(EngineClient):
677
priority: int = 0,
678
data_parallel_rank: int | None = None,
679
session_id: str | None = None,
680
+ kv_hints: KvHintsEnvelope | None = None,
681
reasoning_ended: bool | None = None,
682
reasoning_parser_kwargs: dict[str, Any] | None = None,
683
) -> AsyncGenerator[RequestOutput, None]:
@@ -710,6 +718,7 @@ class AsyncLLM(EngineClient):
718
priority=priority,
719
data_parallel_rank=data_parallel_rank,
720
session_id=session_id,
721
+ kv_hints=kv_hints,
722
prompt_text=prompt_text,
723
reasoning_ended=reasoning_ended,
724
reasoning_parser_kwargs=reasoning_parser_kwargs,
vllm/v1/engine/input_processor.py
+3
@@ -32,6 +32,7 @@ from vllm.utils import length_from_prompt_token_ids_or_embeds, random_uuid
32
from vllm.utils.async_utils import make_async
33
from vllm.utils.jsontree import json_iter_leaves
34
from vllm.v1.engine import EngineCoreRequest
35
+from vllm.v1.kv_hints import KvHintsEnvelope
36
37
logger = init_logger(__name__)
38
@@ -318,6 +319,7 @@ class InputProcessor:
319
data_parallel_rank: int | None = None,
320
resumable: bool = False,
321
session_id: str | None = None,
322
+ kv_hints: KvHintsEnvelope | None = None,
323
) -> EngineCoreRequest:
324
self._validate_params(params, supported_tasks)
325
self._validate_lora(lora_request)
@@ -461,6 +463,7 @@ class InputProcessor:
463
trace_headers=trace_headers,
464
resumable=resumable,
465
session_id=session_id,
466
+ kv_hints=kv_hints,
467
)
468
469
def _validate_prompt_len(
vllm/v1/engine/llm_engine.py
+3
@@ -34,6 +34,7 @@ from vllm.v1.engine.input_processor import InputProcessor
34
from vllm.v1.engine.output_processor import OutputProcessor
35
from vllm.v1.engine.parallel_sampling import ParentRequest
36
from vllm.v1.executor import Executor
37
+from vllm.v1.kv_hints import KvHintsEnvelope
38
from vllm.v1.metrics.loggers import StatLoggerFactory, StatLoggerManager
39
from vllm.v1.metrics.reader import Metric, get_metrics_snapshot
40
from vllm.v1.metrics.stats import IterationStats
@@ -230,6 +231,7 @@ class LLMEngine:
231
priority: int = 0,
232
session_id: str | None = None,
233
prompt_text: str | None = None,
234
+ kv_hints: KvHintsEnvelope | None = None,
235
) -> str:
236
# Validate the request_id type.
237
if not isinstance(request_id, str):
@@ -263,6 +265,7 @@ class LLMEngine:
265
trace_headers=trace_headers,
266
priority=priority,
267
session_id=session_id,
268
+ kv_hints=kv_hints,
269
)
270
prompt_text, _, _ = extract_prompt_components(self.model_config, prompt)
271
vllm/v1/kv_hints/__init__.py
new
+6
@@ -0,0 +1,6 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.v1.kv_hints.protocol import KvHintAction, KvHintsEnvelope
5
+
6
+__all__ = ["KvHintAction", "KvHintsEnvelope"]
vllm/v1/kv_hints/protocol.py
new
+26
@@ -0,0 +1,26 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from typing import Any
5
+
6
+import msgspec
7
+
8
+
9
+class KvHintAction(msgspec.Struct, frozen=True): # type: ignore[call-arg]
10
+ """A versioned orchestrator-provided KV hint action."""
11
+
12
+ action_id: str
13
+ action_type: str
14
+ action_version: str
15
+ # `payload` must contain JSON-compatible values. Action-specific schemas are
16
+ # validated by the component implementing the action.
17
+ payload: dict[str, Any]
18
+
19
+
20
+class KvHintsEnvelope(msgspec.Struct, frozen=True): # type: ignore[call-arg]
21
+ """Orchestrator-provided KV hints optionally attached to one inference request.
22
+ A KV hint envelope is a collection of KV hint action(s)."""
23
+
24
+ protocol_version: str
25
+ message_id: str
26
+ actions: list[KvHintAction]
vllm/v1/kv_offload/base.py
+4
-2
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
16
OffloadingConnectorStats,
17
)
18
19
+from vllm.v1.kv_hints import KvHintsEnvelope
20
from vllm.v1.kv_offload.config import OffloadingConfig
21
22
# `OffloadKey` identifies an offloaded block. It combines a block hash with
@@ -89,10 +90,11 @@ TierFilter.ALL = TierFilter(matchers=(TierMatcher(),))
90
class ReqContext:
91
req_id: str
92
kv_transfer_params: dict[str, Any] | None = None
93
+ kv_hints: KvHintsEnvelope | None = None
94
load_tier_filter: TierFilter = TierFilter.ALL
95
# Per-request scratch space keyed by value type, so a tier can parse
94
- # kv_transfer_params once (in on_new_request) and read the result back
95
- # on later calls for the same request.
96
+ # kv_transfer_params and kv_hints once (in on_new_request) and read the
97
+ # result back on later calls for the same request.
98
_state: dict[type, Any] = field(default_factory=dict, repr=False, init=False)
99
# End-token position for each key in this request. The scheduler records
100
# these positions so managers can recover prefix order even when store
vllm/v1/kv_offload/tiering/kvcr/manager.py
+7
-10
@@ -7,15 +7,15 @@ import mmap
7
import socket
8
import time
9
import uuid
10
-from collections.abc import Collection, Iterable, Mapping
10
+from collections.abc import Collection, Iterable
11
from pathlib import Path
12
from typing import TYPE_CHECKING, Any, cast
13
14
+import msgspec
15
from kvcr import (
16
DURATION_METRIC,
17
KVCR,
18
ROUTER_HINT_CAPABILITIES,
18
- ROUTER_HINT_KEY,
19
STATE_METRIC,
20
TRANSFER_BLOCKS_METRIC,
21
TRANSFER_BYTES_METRIC,
@@ -575,14 +575,11 @@ class KVCRSecondaryTierManager(SecondaryTierManager):
575
576
@override
577
def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
578
- params = getattr(req_context, "kv_transfer_params", None)
579
- if isinstance(params, Mapping):
580
- hint = params.get(ROUTER_HINT_KEY)
581
- if hint is not None:
582
- self._kvcr.submit_hint(
583
- request_id=req_context.req_id,
584
- hints=hint,
585
- )
578
+ if (hint := req_context.kv_hints) is not None:
579
+ self._kvcr.submit_hint(
580
+ request_id=req_context.req_id,
581
+ hints=msgspec.to_builtins(hint),
582
+ )
583
return RequestOffloadingContext()
584
585
@override
vllm/v1/request.py
+4
@@ -20,6 +20,7 @@ from vllm.v1.engine import (
20
EngineCoreRequest,
21
FinishReason,
22
)
23
+from vllm.v1.kv_hints import KvHintsEnvelope
24
from vllm.v1.metrics.stats import PrefillStats, RequestSpecDecodeMetrics
25
from vllm.v1.structured_output.request import StructuredOutputRequest
26
from vllm.v1.utils import ConstantList
@@ -78,6 +79,7 @@ class Request:
79
reasoning_ended: bool | None = None,
80
reasoning_parser_kwargs: dict[str, Any] | None = None,
81
abort_immediately: bool = False,
82
+ kv_hints: KvHintsEnvelope | None = None,
83
) -> None:
84
self.request_id = request_id
85
self.client_index = client_index
@@ -193,6 +195,7 @@ class Request:
195
# trace_headers
196
self.trace_headers = trace_headers
197
self.session_id = session_id
198
+ self.kv_hints = kv_hints
199
200
# True if this request is scheduled as a non-final prefill chunk.
201
self.is_prefill_chunk = False
@@ -260,6 +263,7 @@ class Request:
263
block_hasher=block_hasher,
264
resumable=request.resumable,
265
session_id=request.session_id,
266
+ kv_hints=request.kv_hints,
267
reasoning_ended=request.reasoning_ended,
268
reasoning_parser_kwargs=request.reasoning_parser_kwargs,
269
abort_immediately=request.abort_immediately,