Skip to content

Commit 712a930

Browse files
jeffreywang88claude
andcommitted
[serve][llm] Tokenize pre-routing requests in-process via the engine's vLLM renderer
Replace the per-request /tokenize RPC with an in-process tokenizer that replays vLLM's ServingTokenization.create_tokenize on the engine's own OnlineRenderer, so routing token ids are byte-identical to the replica's prefill tokens without contending with inference. vLLM imports are hoisted to module top (needs OnlineRenderer, vLLM >= 0.25) and the ingress router imports the module lazily so non-KV paths never pull the renderer in. Signed-off-by: Jeffrey Wang <jeffreywang@anyscale.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b398518 commit 712a930

8 files changed

Lines changed: 336 additions & 172 deletions

File tree

python/ray/llm/_internal/serve/core/ingress/builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def _build_openai_ingress_request_router(
104104
)
105105
return deployment.bind(
106106
server=server,
107-
pre_routing_tokenization=is_kv_aware(llm_config),
107+
llm_config=llm_config if is_kv_aware(llm_config) else None,
108108
)
109109

110110

python/ray/llm/_internal/serve/core/ingress/router.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1+
import asyncio
12
import json
23
from types import SimpleNamespace
3-
from typing import List, Optional, Tuple
4+
from typing import TYPE_CHECKING, List, Optional, Tuple
45

56
from fastapi import FastAPI, HTTPException, Request
67

78
from ray import serve
89
from ray.llm._internal.serve.observability.logging import get_logger
9-
from ray.llm._internal.serve.routing_policies.kv_aware.tokenizer import (
10+
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
1011
REQUEST_TOKEN_IDS_KWARG,
11-
TokenizeError,
12-
Tokenizer,
1312
)
1413
from ray.serve._private.http_util import _matches_session_id_header
1514
from ray.serve.exceptions import DeploymentUnavailableError
1615
from ray.serve.handle import DeploymentHandle
1716

17+
# Type-only import as LLMConfig transitively pulls in vLLM. This file should
18+
# remain engine-agnostic.
19+
if TYPE_CHECKING:
20+
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
21+
1822
logger = get_logger(__name__)
1923

2024
_BODY_TRUNCATED_HEADER = "x-body-truncated"
@@ -105,13 +109,23 @@ class LLMRouter:
105109
_warned_no_routing_key: bool = False
106110

107111
async def __init__(
108-
self, server: DeploymentHandle, pre_routing_tokenization: bool = False
112+
self,
113+
server: DeploymentHandle,
114+
llm_config: Optional["LLMConfig"] = None,
109115
):
110116
self._handle: DeploymentHandle = server
111117
self._handle._init()
112-
# Pre-routing tokenization is only useful to a KV-aware request router,
113-
# which scores replicas based on the prompt token IDs.
114-
self._tokenizer = Tokenizer(self._handle) if pre_routing_tokenization else None
118+
self._tokenizer = None
119+
# A non-None llm_config signals pre-routing tokenization, which the
120+
# builder binds only for a KV-aware request router.
121+
if llm_config is not None:
122+
# Lazy import: this module pulls in vLLM's renderer;
123+
# keep it off the non-KV ingress import path.
124+
from ray.llm._internal.serve.routing_policies.kv_aware.tokenizer import (
125+
Tokenizer,
126+
)
127+
128+
self._tokenizer = await asyncio.to_thread(Tokenizer, llm_config)
115129

116130
@router_app.post("/internal/route")
117131
async def route(self, request: Request):
@@ -133,6 +147,10 @@ async def route(self, request: Request):
133147
# body has no routing payload, so fall back to token-less routing.
134148
request_token_ids = None
135149
if self._tokenizer is not None and routing_payload is not None:
150+
from ray.llm._internal.serve.routing_policies.kv_aware.tokenizer import (
151+
TokenizeError,
152+
)
153+
136154
try:
137155
request_token_ids = await self._tokenizer.tokenize(
138156
vars(routing_payload)

python/ray/llm/_internal/serve/routing_policies/kv_aware/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# choose_replica kwarg carrying the prompt token IDs to KV-aware routers.
2+
REQUEST_TOKEN_IDS_KWARG = "request_token_ids"
3+
14
# experimental_configs key overriding the per-node base port.
25
KV_EVENTS_PORT_BASE_KEY = "KV_EVENTS_PORT_BASE"
36
DEFAULT_KV_EVENTS_PORT_BASE = 5557

python/ray/llm/_internal/serve/routing_policies/kv_aware/kv_aware_router.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
import ray
66
from ray.actor import ActorHandle
77
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
8+
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
9+
REQUEST_TOKEN_IDS_KWARG,
10+
)
811
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
912
KV_ROUTER_ACTOR_NAME,
1013
get_worker_id,
1114
)
12-
from ray.llm._internal.serve.routing_policies.kv_aware.tokenizer import (
13-
REQUEST_TOKEN_IDS_KWARG,
14-
)
1515
from ray.serve._private.constants import (
1616
SERVE_DEPLOYMENT_ACTOR_PREFIX,
1717
SERVE_LOGGER_NAME,
Lines changed: 132 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
from typing import Any, Dict, List, Optional, Union
22

3+
import jinja2
4+
from vllm.entrypoints.chat_utils import load_chat_template
5+
from vllm.entrypoints.openai.cli_args import FrontendArgs
6+
from vllm.renderers import renderer_from_config
7+
from vllm.renderers.inputs.preprocess import extract_prompt_components
8+
from vllm.renderers.online_renderer import OnlineRenderer
9+
10+
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
311
from ray.llm._internal.serve.core.configs.openai_api_models import (
4-
ErrorResponse,
512
TokenizeChatRequest,
613
TokenizeCompletionRequest,
714
)
15+
from ray.llm._internal.serve.engines.vllm.vllm_engine import (
16+
_get_vllm_engine_config,
17+
)
818
from ray.llm._internal.serve.observability.logging import get_logger
9-
from ray.serve.handle import DeploymentHandle
1019

1120
logger = get_logger(__name__)
1221

13-
# choose_replica kwarg carrying the prompt token IDs to KV-aware routers.
14-
REQUEST_TOKEN_IDS_KWARG = "request_token_ids"
15-
1622

1723
class TokenizeError(Exception):
18-
"""The ``/tokenize`` endpoint rejected the request.
24+
"""The request was rejected the same way vLLM's native ASGI route
25+
``/tokenize`` would reject it.
1926
20-
Carries vLLM's HTTP ``status_code``, ``message`` and error ``type``.
27+
Carries the HTTP ``status_code``, ``message`` and error ``type``.
2128
"""
2229

2330
def __init__(self, message: str, *, status_code: int, type: str):
@@ -27,15 +34,77 @@ def __init__(self, message: str, *, status_code: int, type: str):
2734
self.type = type
2835

2936

37+
def build_tokenize_request(
38+
payload: Dict[str, Any]
39+
) -> Optional[Union[TokenizeChatRequest, TokenizeCompletionRequest]]:
40+
"""Build the Tokenize* request for ``payload``, forwarding only the fields
41+
the engine renders the prompt from so routing ids match the prefill tokens.
42+
43+
Returns ``None`` (caller falls back to token-less routing) for a body with
44+
no single string prompt, e.g. a batch ``prompt`` list, since KV-aware
45+
routing scores one request on one token sequence.
46+
47+
TODO (jeffreywang): Support multi-prompt tokenization.
48+
"""
49+
try:
50+
if "messages" in payload:
51+
return TokenizeChatRequest.model_validate(
52+
{
53+
k: v
54+
for k, v in payload.items()
55+
if k in TokenizeChatRequest.model_fields
56+
}
57+
)
58+
if "prompt" in payload:
59+
if not isinstance(payload["prompt"], str):
60+
return None
61+
return TokenizeCompletionRequest.model_validate(
62+
{
63+
k: v
64+
for k, v in payload.items()
65+
if k in TokenizeCompletionRequest.model_fields
66+
}
67+
)
68+
# Unreachable: LLMRouter only routes bodies with messages or a prompt.
69+
logger.warning(
70+
"Tokenizer got a payload with neither messages nor prompt; "
71+
"falling back to token-less routing."
72+
)
73+
return None
74+
except Exception as e:
75+
logger.warning("Unsupported tokenize request, falling back: %s", e)
76+
return None
77+
78+
3079
class Tokenizer:
31-
"""Tokenizes incoming requests via the replica's ``/tokenize`` endpoint.
80+
"""Tokenizes requests with vLLM's ``OnlineRenderer``.
81+
82+
Configured from the deployment's frontend args so the tokenizer, chat
83+
template, and trust policy match the engine's.
3284
3385
Args:
34-
handle: A handle to the LLMServer deployment.
86+
llm_config: The deployment's LLM config.
3587
"""
3688

37-
def __init__(self, handle: DeploymentHandle):
38-
self._handle = handle
89+
def __init__(self, llm_config: LLMConfig):
90+
engine_config = llm_config.get_engine_config()
91+
_, vllm_config = _get_vllm_engine_config(llm_config)
92+
self._model_config = vllm_config.model_config
93+
94+
frontend_args = FrontendArgs(**engine_config.frontend_kwargs)
95+
self._renderer = OnlineRenderer(
96+
self._model_config,
97+
renderer_from_config(vllm_config),
98+
request_logger=None,
99+
chat_template=load_chat_template(frontend_args.chat_template),
100+
chat_template_content_format=frontend_args.chat_template_content_format,
101+
trust_request_chat_template=frontend_args.trust_request_chat_template,
102+
default_chat_template_kwargs=frontend_args.default_chat_template_kwargs,
103+
)
104+
logger.info(
105+
"In-process pre-routing tokenizer ready for %s",
106+
self._model_config.model,
107+
)
39108

40109
async def tokenize(self, payload: Dict[str, Any]) -> Optional[List[int]]:
41110
"""Tokenize a request ``payload`` into prompt token IDs.
@@ -49,76 +118,60 @@ async def tokenize(self, payload: Dict[str, Any]) -> Optional[List[int]]:
49118
Raises:
50119
TokenizeError: The ``/tokenize`` endpoint rejected the request.
51120
"""
52-
tok_req = self._build_tokenize_request(payload)
53-
if tok_req is None:
121+
request = build_tokenize_request(payload)
122+
if request is None:
54123
return None
55124

56-
# /tokenize yields a single response; drain the stream fully so the
57-
# handle response is cleaned up.
58-
resp = None
59-
async for chunk in self._handle.options(stream=True).tokenize.remote(
60-
tok_req, None
61-
):
62-
resp = chunk
63-
if resp is None:
64-
raise TokenizeError(
65-
"/tokenize returned no response",
66-
status_code=500,
67-
type="internal_error",
68-
)
69-
if isinstance(resp, ErrorResponse):
70-
raise TokenizeError(
71-
resp.error.message,
72-
status_code=resp.error.code,
73-
type=resp.error.type,
74-
)
75-
return list(resp.tokens)
76-
77-
def _build_tokenize_request(
78-
self, payload: Dict[str, Any]
79-
) -> Optional[Union[TokenizeChatRequest, TokenizeCompletionRequest]]:
80-
"""Build the Tokenize* request for ``payload``.
81-
82-
KV-aware routing sends each request to one replica, scored on a single
83-
prompt's token sequence, so we return ``None`` (the caller falls back to
84-
token-less routing) for bodies that don't have exactly one prompt:
85-
- A non-string ``prompt``: an OpenAI *batch* completion where ``prompt``
86-
is a list, e.g. ``{"prompt": ["q1", "q2"]}`` (or pre-tokenized id
87-
lists). N prompts give N token sequences, so there's no single key to
88-
route the one request on.
89-
90-
TODO (jeffreywang): Support multi-prompt tokenization.
91-
"""
92125
try:
93-
if "messages" in payload:
94-
# Forward every request field the engine renders the prompt from
95-
# so the routing token IDs match the prefill tokens.
96-
return TokenizeChatRequest.model_validate(
97-
{
98-
k: v
99-
for k, v in payload.items()
100-
if k in TokenizeChatRequest.model_fields
101-
}
126+
if isinstance(request, TokenizeChatRequest):
127+
engine_inputs = await self._render_chat(request)
128+
else:
129+
engine_inputs = await self._renderer.preprocess_completion(
130+
request,
131+
prompt_input=request.prompt,
132+
prompt_embeds=None,
133+
skip_mm_cache=True,
102134
)
103-
if "prompt" in payload:
104-
if not isinstance(payload["prompt"], str):
105-
# TODO (jeffreywang): Multi-prompt (list) tokenization is unsupported;
106-
# fall back to token-less routing.
107-
return None
108-
return TokenizeCompletionRequest.model_validate(
109-
{
110-
k: v
111-
for k, v in payload.items()
112-
if k in TokenizeCompletionRequest.model_fields
113-
}
114-
)
115-
# Should be unreachable: LLMRouter only routes bodies with messages
116-
# or a prompt (see _parse_routing_payload).
117-
logger.warning(
118-
"Tokenizer got a payload with neither messages nor prompt; "
119-
"falling back to token-less routing."
135+
except TokenizeError:
136+
raise
137+
except (ValueError, jinja2.TemplateError) as e:
138+
# /tokenize maps bad inputs and chat-template errors to 400; other
139+
# exceptions are real bugs and should surface, not degrade routing.
140+
raise TokenizeError(str(e), status_code=400, type="BadRequestError")
141+
142+
input_ids: List[int] = []
143+
for engine_input in engine_inputs:
144+
components = extract_prompt_components(self._model_config, engine_input)
145+
if components.token_ids is not None:
146+
input_ids.extend(components.token_ids)
147+
return input_ids
148+
149+
async def _render_chat(self, request: TokenizeChatRequest):
150+
# Refuse a request-supplied chat template unless the deployment opted in.
151+
error = self._renderer.validate_chat_template(
152+
request_chat_template=request.chat_template,
153+
chat_template_kwargs=request.chat_template_kwargs,
154+
trust_request_chat_template=self._renderer.trust_request_chat_template,
155+
)
156+
if error is not None:
157+
raise TokenizeError(
158+
error.error.message,
159+
status_code=error.error.code,
160+
type=error.error.type,
120161
)
121-
return None
122-
except Exception as e:
123-
logger.debug("Unsupported tokenize request, falling back: %s", e)
124-
return None
162+
163+
tool_dicts = (
164+
None
165+
if request.tools is None
166+
else [tool.model_dump() for tool in request.tools]
167+
)
168+
_, engine_inputs = await self._renderer.preprocess_chat(
169+
request,
170+
request.messages,
171+
default_template=self._renderer.chat_template,
172+
default_template_content_format=self._renderer.chat_template_content_format,
173+
default_template_kwargs=self._renderer.default_chat_template_kwargs,
174+
tool_dicts=tool_dicts,
175+
skip_mm_cache=True,
176+
)
177+
return engine_inputs

python/ray/llm/tests/serve/cpu/deployments/routers/kv/test_kv_aware_router.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@
2323
LLMServingArgs,
2424
build_openai_app,
2525
)
26+
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
27+
REQUEST_TOKEN_IDS_KWARG,
28+
)
2629
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
2730
KV_ROUTER_ACTOR_NAME,
2831
KVRouterActor,
2932
get_worker_id,
3033
)
31-
from ray.llm._internal.serve.routing_policies.kv_aware.tokenizer import (
32-
REQUEST_TOKEN_IDS_KWARG,
33-
)
3434
from ray.serve._private.common import (
3535
REPLICA_ID_FULL_ID_STR_PREFIX,
3636
DeploymentID,

0 commit comments

Comments
 (0)