[Core][Frontend] Add weight version tagging for RL rollouts (#49040)

Signed-off-by: Shuolei Wang <shuoleiwang123@gmail.com> Signed-off-by: Shuolei Wang <948904026@qq.com>

Shuolei Wang committed Jul 28, 2026 at 14:35 UTC 9069a57139bc075733ffb82c228d9f33945e524b
16 files changed +142 -18
docs/serving/offline_inference.md
+2
@@ -65,6 +65,8 @@ For further details on Weight Transfer, please refer to [this page](../training/
65 - `LLM.start_weight_update` - Starts a new weight update cycle.
66 - `LLM.update_weights` - Updates the model weights.
67 - `LLM.finish_weight_update` - Finishes the current weight update cycle.
68 +- `LLM.update_weight_version` - Sets the weight version without updating model weights.
69 +- `LLM.get_weight_version` - Returns the latest committed weight version.
70
71 ## Additional APIs
72
docs/serving/online_serving/README.md
+2
@@ -179,6 +179,8 @@ For further details on Weight Transfer, please refer to [this page](../../traini
179 - `/start_weight_update` - Prepares the inference engine for a weight update.
180 - `/update_weights` - Update model weights (can alter model behavior)
181 - `/finish_weight_update` - Finalizes the weight update
182 +- `/update_weight_version` - Set the weight version without updating model weights
183 +- `/weight_info` - Get the latest committed weight version
184 - `/get_world_size` - Get distributed world size
185
186 ### Collective RPC
docs/training/async_rl.md
+2 -1
@@ -38,11 +38,12 @@ Resumes the scheduler after a pause. Any requests frozen with `mode="keep"` will
38
39 ### HTTP Endpoints
40
41 -When using the vLLM HTTP server, the same functionality is available via:
41 +With `VLLM_SERVER_DEV_MODE=1`, the vLLM HTTP server exposes the same functionality via:
42
43 - `POST /pause?mode=keep` - Pause generation
44 - `POST /resume` - Resume generation
45 - `POST /abort_requests` - Abort in-flight requests without pausing the scheduler (send `{}` to abort all, or `{"request_ids": [...]}`)
46 +- `GET /weight_info` - Return the latest committed `weight_version`
47
48 !!! note "Data Parallelism"
49 When using data parallelism with vLLM's **internal load balancer** (i.e. `data_parallel_backend="ray"`), pause and resume are handled automatically across all DP ranks -- a single call is sufficient. When using an **external load balancer** (i.e. multiple independent vLLM instances behind a proxy), you must send pause and resume requests to **every** engine instance individually before and after the weight update.
docs/training/weight_transfer/README.md
+4 -2
@@ -53,7 +53,9 @@ When running vLLM as an HTTP server, the following endpoints are available for w
53 | `/init_weight_transfer_engine` | POST | Initialize the weight transfer engine with backend-specific info |
54 | `/start_weight_update` | POST | Start a weight update |
55 | `/update_weights` | POST | Transfer a batch of weights with backend-specific metadata |
56 -| `/finish_weight_update` | POST | Finish the weight update and run post-processing |
56 +| `/finish_weight_update` | POST | Finish the update and optionally commit its `weight_version` |
57 +| `/update_weight_version` | POST | Update `weight_version` without changing model weights |
58 +| `/weight_info` | GET | Get the latest committed weight version |
59 | `/pause` | POST | Pause generation before weight sync to handle inflight requests |
60 | `/resume` | POST | Resume generation after weight sync |
61 | `/get_world_size` | GET | Get the number of inference workers (useful for NCCL world size calculation) |
@@ -79,7 +81,7 @@ EngineClass.trainer_send_weights(
81 )
82
83 # 4. Finish weight update on inference side
82 -llm.finish_weight_update()
84 +llm.finish_weight_update(weight_version="step-42")
85 ```
86
87 See the [NCCL](nccl.md) and [IPC](ipc.md) pages for backend-specific trainer APIs and full examples.
tests/distributed/test_weight_transfer.py
+8 -1
@@ -1247,7 +1247,7 @@ class RecordingClient:
1247 self.order.append("update")
1248 self.last_update_info = update_info
1249
1250 - def finish_weight_update(self) -> None:
1250 + def finish_weight_update(self, weight_version: str | None = None) -> None:
1251 self.order.append("finish")
1252
1253
@@ -1303,6 +1303,10 @@ class TestTrainerClients:
1303 assert isinstance(update_req, WeightTransferUpdateRequest)
1304 assert update_req.update_info == {"names": ["w"]}
1305
1306 + client.finish_weight_update("step-42")
1307 + handle.finish_weight_update.remote.assert_called_once_with()
1308 + handle.update_weight_version.remote.assert_called_once_with("step-42")
1309 +
1310 def test_http_client_pickles_ipc_handles_for_json(self, monkeypatch):
1311 """HTTP update_weights must encode raw ipc_handles as a base64 pickle."""
1312 captured = {}
@@ -1334,6 +1338,9 @@ class TestTrainerClients:
1338 client.update_weights(update_info)
1339 assert captured["json"]["update_info"] == update_info
1340
1341 + client.finish_weight_update("step-42")
1342 + assert captured["json"] == {"weight_version": "step-42"}
1343 +
1344
1345 class TestModuleSource:
1346 """`ModuleSource` metadata vs. materialized iteration (dense, no GPU)."""
tests/entrypoints/openai/test_openai_schema.py
+1
@@ -148,6 +148,7 @@ def test_openapi_stateless(case: schemathesis.Case):
148 "/start_draft_weight_update",
149 "/update_weights",
150 "/finish_weight_update",
151 + "/update_weight_version",
152 ):
153 return
154
tests/entrypoints/weight_transfer/test_weight_transfer_llm.py
+11 -1
@@ -234,6 +234,7 @@ def test_update_weights_calls_engine():
234 assert shapes == test_shapes
235
236 llm.finish_weight_update()
237 + assert llm.get_weight_version() == "default"
238
239
240 @create_new_process_for_each_test()
@@ -259,6 +260,8 @@ def test_full_weight_transfer_flow():
260 weight_transfer_config=WeightTransferConfig(backend="nccl"),
261 )
262
263 + assert llm.get_weight_version() == "default"
264 +
265 # Step 1: Initialize weight transfer engine
266 llm.init_weight_transfer_engine(
267 WeightTransferInitRequest(init_info={"test_param": "flow_test"})
@@ -278,8 +281,15 @@ def test_full_weight_transfer_flow():
281 )
282 )
283
284 + assert llm.get_weight_version() == "default"
285 +
286 # Step 4: Finish weight update
282 - llm.finish_weight_update()
287 + llm.finish_weight_update("step-42")
288 +
289 + assert llm.get_weight_version() == "step-42"
290 +
291 + llm.update_weight_version("manual-version")
292 + assert llm.get_weight_version() == "manual-version"
293
294 # Verify the full flow completed
295 def check_flow(self):
vllm/distributed/weight_transfer/base.py
+1 -1
@@ -370,7 +370,7 @@ class VLLMWeightSyncClient(Protocol):
370
371 def update_weights(self, update_info: dict[str, Any]) -> None: ...
372
373 - def finish_weight_update(self) -> None: ...
373 + def finish_weight_update(self, weight_version: str | None = None) -> None: ...
374
375
376 class TrainerWeightTransferEngine(ABC, Generic[TConfig, TInitInfo]):
vllm/distributed/weight_transfer/clients.py
+10 -3
@@ -77,8 +77,11 @@ class HTTPVLLMWeightSyncClient:
77 "update_weights", {"update_info": _json_safe_update_info(update_info)}
78 )
79
80 - def finish_weight_update(self) -> None:
81 - self._post("finish_weight_update")
80 + def finish_weight_update(self, weight_version: str | None = None) -> None:
81 + json = (
82 + {"weight_version": weight_version} if weight_version is not None else None
83 + )
84 + self._post("finish_weight_update", json)
85
86
87 class RayVLLMWeightSyncClient:
@@ -108,7 +111,11 @@ class RayVLLMWeightSyncClient:
111 request = WeightTransferUpdateRequest(update_info=update_info)
112 ray.get([h.update_weights.remote(request) for h in self.handles])
113
111 - def finish_weight_update(self) -> None:
114 + def finish_weight_update(self, weight_version: str | None = None) -> None:
115 import ray
116
117 ray.get([h.finish_weight_update.remote() for h in self.handles])
118 + if weight_version is not None:
119 + ray.get(
120 + [h.update_weight_version.remote(weight_version) for h in self.handles]
121 + )
vllm/engine/protocol.py
+10 -2
@@ -267,6 +267,14 @@ class EngineClient(ABC):
267 """Batched weight update for RL training."""
268 raise NotImplementedError
269
270 - async def finish_weight_update(self) -> None:
271 - """Finish the current weight update."""
270 + async def finish_weight_update(self, weight_version: str | None = None) -> None:
271 + """Finish the weight update and set its version if provided."""
272 + raise NotImplementedError
273 +
274 + async def update_weight_version(self, new_version: str) -> None:
275 + """Set the weight version without updating weights."""
276 + raise NotImplementedError
277 +
278 + async def get_weight_version(self) -> str:
279 + """Return the latest committed weight version."""
280 raise NotImplementedError
vllm/entrypoints/llm.py
+12 -2
@@ -885,9 +885,19 @@ class LLM(BeamSearchOfflineMixin, PoolingOfflineMixin, OfflineInferenceMixin):
885 "update_weights", kwargs={"update_info": update_info_dict}
886 )
887
888 - def finish_weight_update(self) -> None:
889 - """Finish the current weight update."""
888 + def finish_weight_update(self, weight_version: str | None = None) -> None:
889 + """Finish the weight update and set its version if provided."""
890 self.llm_engine.collective_rpc("finish_weight_update")
891 + if weight_version is not None:
892 + self.llm_engine.set_weight_version(weight_version)
893 +
894 + def update_weight_version(self, new_version: str) -> None:
895 + """Set the weight version without updating weights."""
896 + self.llm_engine.set_weight_version(new_version)
897 +
898 + def get_weight_version(self) -> str:
899 + """Return the latest committed weight version."""
900 + return self.llm_engine.get_weight_version()
901
902 def __repr__(self) -> str:
903 """Return a transformers-style hierarchical view of the model."""
vllm/entrypoints/serve/dev/rlhf/api_router.py
+21 -3
@@ -5,7 +5,7 @@ import json
5 from http import HTTPStatus
6 from typing import Annotated
7
8 -from fastapi import APIRouter, FastAPI, HTTPException, Query, Request
8 +from fastapi import APIRouter, Body, FastAPI, HTTPException, Query, Request
9 from fastapi.responses import JSONResponse
10
11 from vllm.distributed.weight_transfer.base import (
@@ -203,11 +203,29 @@ async def update_weights(raw_request: Request):
203
204
205 @router.post("/finish_weight_update")
206 -async def finish_weight_update(raw_request: Request):
207 - await engine_client(raw_request).finish_weight_update()
206 +async def finish_weight_update(
207 + raw_request: Request,
208 + weight_version: Annotated[str | None, Body(embed=True)] = None,
209 +):
210 + await engine_client(raw_request).finish_weight_update(weight_version)
211 return JSONResponse(content={"message": "Weight update finished"})
212
213
214 +@router.post("/update_weight_version")
215 +async def update_weight_version(
216 + raw_request: Request,
217 + new_version: Annotated[str, Body(embed=True)],
218 +):
219 + await engine_client(raw_request).update_weight_version(new_version)
220 + return JSONResponse(content={"success": True, "new_version": new_version})
221 +
222 +
223 +@router.get("/weight_info")
224 +async def weight_info(raw_request: Request):
225 + weight_version = await engine_client(raw_request).get_weight_version()
226 + return JSONResponse(content={"weight_version": weight_version})
227 +
228 +
229 @router.get("/get_world_size")
230 async def get_world_size(
231 raw_request: Request,
vllm/v1/engine/async_llm.py
+12 -2
@@ -1106,6 +1106,16 @@ class AsyncLLM(EngineClient):
1106 "update_weights", kwargs={"update_info": request.update_info}
1107 )
1108
1109 - async def finish_weight_update(self) -> None:
1110 - """Finish the current weight update."""
1109 + async def finish_weight_update(self, weight_version: str | None = None) -> None:
1110 + """Finish the weight update and set its version if provided."""
1111 await self.collective_rpc("finish_weight_update")
1112 + if weight_version is not None:
1113 + await self.update_weight_version(weight_version)
1114 +
1115 + async def update_weight_version(self, new_version: str) -> None:
1116 + """Set the weight version without updating weights."""
1117 + await self.engine_core.set_weight_version_async(new_version)
1118 +
1119 + async def get_weight_version(self) -> str:
1120 + """Return the latest committed weight version."""
1121 + return await self.engine_core.get_weight_version_async()
vllm/v1/engine/core.py
+9
@@ -125,6 +125,8 @@ class EngineCore:
125 )
126
127 self.log_stats = log_stats
128 + # Opaque weight version supplied by the caller.
129 + self._weight_version = "default"
130
131 # Setup Model.
132 self.model_executor = executor_class(vllm_config)
@@ -956,6 +958,13 @@ class EngineCore:
958 ) -> list[_R]:
959 return self.model_executor.collective_rpc(method, timeout, args, kwargs)
960
961 + def set_weight_version(self, weight_version: str) -> None:
962 + self._weight_version = weight_version
963 +
964 + def get_weight_version(self) -> str:
965 + """Return the latest committed weight version."""
966 + return self._weight_version
967 +
968 def preprocess_add_request(self, request: EngineCoreRequest) -> tuple[Request, int]:
969 """Preprocess the request.
970
vllm/v1/engine/core_client.py
+30
@@ -176,9 +176,21 @@ class EngineCoreClient(ABC):
176 def execute_dummy_batch(self) -> None:
177 raise NotImplementedError
178
179 + def set_weight_version(self, weight_version: str) -> None:
180 + raise NotImplementedError
181 +
182 + def get_weight_version(self) -> str:
183 + raise NotImplementedError
184 +
185 async def execute_dummy_batch_async(self) -> None:
186 raise NotImplementedError
187
188 + async def set_weight_version_async(self, weight_version: str) -> None:
189 + raise NotImplementedError
190 +
191 + async def get_weight_version_async(self) -> str:
192 + raise NotImplementedError
193 +
194 def abort_requests(self, request_ids: list[str]) -> None:
195 raise NotImplementedError
196
@@ -351,6 +363,12 @@ class InprocClient(EngineCoreClient):
363 def execute_dummy_batch(self) -> None:
364 self.engine_core.execute_dummy_batch()
365
366 + def set_weight_version(self, weight_version: str) -> None:
367 + self.engine_core.set_weight_version(weight_version)
368 +
369 + def get_weight_version(self) -> str:
370 + return self.engine_core.get_weight_version()
371 +
372 def add_lora(self, lora_request: LoRARequest) -> bool:
373 return self.engine_core.add_lora(lora_request)
374
@@ -947,6 +965,12 @@ class SyncMPClient(MPClient):
965 def execute_dummy_batch(self) -> None:
966 self.call_utility("execute_dummy_batch")
967
968 + def set_weight_version(self, weight_version: str) -> None:
969 + self.call_utility("set_weight_version", weight_version)
970 +
971 + def get_weight_version(self) -> str:
972 + return self.call_utility("get_weight_version")
973 +
974 def collective_rpc(
975 self,
976 method: str | Callable[..., _R],
@@ -1199,6 +1223,12 @@ class AsyncMPClient(MPClient):
1223 async def execute_dummy_batch_async(self) -> None:
1224 await self.call_utility_async("execute_dummy_batch")
1225
1226 + async def set_weight_version_async(self, weight_version: str) -> None:
1227 + await self.call_utility_async("set_weight_version", weight_version)
1228 +
1229 + async def get_weight_version_async(self) -> str:
1230 + return await self.call_utility_async("get_weight_version")
1231 +
1232 async def add_lora_async(self, lora_request: LoRARequest) -> bool:
1233 return await self.call_utility_async("add_lora", lora_request)
1234
vllm/v1/engine/llm_engine.py
+7
@@ -425,6 +425,13 @@ class LLMEngine:
425 ) -> list[_R]:
426 return self.engine_core.collective_rpc(method, timeout, args, kwargs)
427
428 + def set_weight_version(self, weight_version: str) -> None:
429 + self.engine_core.set_weight_version(weight_version)
430 +
431 + def get_weight_version(self) -> str:
432 + """Return the latest committed weight version."""
433 + return self.engine_core.get_weight_version()
434 +
435 def apply_model(self, func: Callable[[nn.Module], _R]) -> list[_R]:
436 return self.collective_rpc("apply_model", args=(func,))
437