main
py 502 lines 17.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Generative Scoring implementation for generative models.
4
5 This module implements generative scoring functionality that computes the
6 probability of specified token IDs appearing as the next token after a
7 given query+item prompt. This works on any generative model that produces
8 logits (task="generate").
9 """
10
11 import asyncio
12 import math
13 import time
14 from collections.abc import AsyncGenerator, Mapping
15 from typing import Literal
16
17 from fastapi import Request
18 from pydantic import Field
19
20 from vllm.engine.protocol import EngineClient
21 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
22 from vllm.entrypoints.serve.engine.protocol import (
23 ErrorResponse,
24 OpenAIBaseModel,
25 UsageInfo,
26 )
27 from vllm.entrypoints.serve.engine.serving import BaseServing
28 from vllm.entrypoints.serve.utils.request_logger import RequestLogger
29 from vllm.inputs import EngineInput, tokens_input
30 from vllm.logger import init_logger
31 from vllm.outputs import RequestOutput
32 from vllm.sampling_params import SamplingParams
33 from vllm.tokenizers import TokenizerLike
34 from vllm.tracing import (
35 contains_trace_headers,
36 extract_trace_headers,
37 log_tracing_disabled_warning,
38 )
39 from vllm.utils import random_uuid
40 from vllm.utils.async_utils import merge_async_iterators
41
42 logger = init_logger(__name__)
43
44
45 # ============================================================================
46 # Protocol definitions
47 # ============================================================================
48
49
50 class GenerativeScoringRequest(OpenAIBaseModel):
51 """Request for computing generative scoring.
52
53 Attributes:
54 model: The model to use for scoring. Optional, follows existing patterns.
55 query: The query text or pre-tokenized query token IDs.
56 items: The item text(s) or pre-tokenized item token IDs.
57 label_token_ids: List of token IDs to compute probabilities for.
58 apply_softmax: Whether to normalize probabilities using softmax over only
59 the label_token_ids (True) or return true model probabilities over
60 the full vocab for those ids (False).
61 item_first: If True, prepend items to query. Otherwise append items to query.
62 add_special_tokens: Whether to add special tokens when tokenizing.
63
64 """
65
66 model: str | None = None
67 query: str | list[int] = Field(
68 ...,
69 description="The query text or pre-tokenized query token IDs.",
70 )
71 items: list[str] | list[list[int]] = Field(
72 ...,
73 description="List of item texts or pre-tokenized item token IDs.",
74 )
75 label_token_ids: list[int] = Field(
76 ...,
77 description="List of token IDs to compute probabilities for.",
78 )
79 apply_softmax: bool = Field(
80 default=True,
81 description=(
82 "If True, normalize probabilities using softmax over only the "
83 "label_token_ids. If False, return the true model probabilities "
84 "over the full vocab for those ids."
85 ),
86 )
87 item_first: bool = Field(
88 default=False,
89 description="If True, prepend items to query. Otherwise append items to query.",
90 )
91 add_special_tokens: bool = Field(
92 default=True,
93 description="Whether to add special tokens when tokenizing.",
94 )
95 priority: int = Field(
96 default=0,
97 ge=-(2**63),
98 le=2**63 - 1,
99 description=(
100 "The priority of the request (lower means earlier handling; default: 0)."
101 ),
102 )
103 request_id: str = Field(
104 default_factory=random_uuid,
105 description="The request_id related to this request.",
106 )
107
108
109 class GenerativeScoringItemResult(OpenAIBaseModel):
110 """Result for a single item in the generative scoring response.
111
112 Attributes:
113 index: The index of this item in the input items list.
114 object: Type of object, always "score".
115 score: The probability score for the first label token.
116
117 """
118
119 index: int
120 object: Literal["score"] = "score"
121 score: float
122
123
124 class GenerativeScoringResponse(OpenAIBaseModel):
125 """Response from the generative scoring computation.
126
127 Attributes:
128 id: Unique identifier for this response.
129 object: Type of object, always "list".
130 created: Unix timestamp of when the response was created.
131 model: The model used for scoring.
132 data: List of scoring results, one per input item.
133 usage: Token usage information.
134
135 """
136
137 id: str = Field(default="")
138 object: Literal["list"] = "list"
139 created: int = Field(default_factory=lambda: int(time.time()))
140 model: str
141 data: list[GenerativeScoringItemResult]
142 usage: UsageInfo
143
144
145 # ============================================================================
146 # Serving class
147 # ============================================================================
148
149
150 class ServingGenerativeScoring(BaseServing):
151 """Serving class for generative scoring computation.
152
153 This class handles computing the probability of specified token IDs
154 appearing as the next token after concatenating query and item prompts.
155
156 The key operation is:
157 1. For each item, build a prompt: query + item (or item + query if item_first)
158 2. Run a forward pass to get the next token distribution
159 3. Extract probabilities for the specified label_token_ids
160 4. Normalize either over the full vocab (apply_softmax=False) or
161 over just the label_token_ids (apply_softmax=True)
162 """
163
164 def __init__(
165 self,
166 engine_client: EngineClient,
167 models: OpenAIServingModels,
168 *,
169 request_logger: RequestLogger | None,
170 ) -> None:
171 super().__init__(
172 models=models,
173 model_config=engine_client.model_config,
174 request_logger=request_logger,
175 )
176 self.engine_client = engine_client
177 self.renderer = engine_client.renderer
178
179 async def create_generative_scoring(
180 self,
181 request: GenerativeScoringRequest,
182 raw_request: Request | None = None,
183 ) -> GenerativeScoringResponse | ErrorResponse:
184 """Create generative scoring for the given request.
185
186 Args:
187 request: The GenerativeScoringRequest containing query, items, and
188 label_token_ids.
189 raw_request: The raw FastAPI request object.
190
191 Returns:
192 GenerativeScoringResponse with probabilities for each item, or
193 ErrorResponse if an error occurred.
194
195 """
196 # Check model
197 error_check_ret = await self._check_model(request) # type: ignore[arg-type]
198 if error_check_ret is not None:
199 return error_check_ret
200
201 # Check if engine is alive
202 if self.engine_client.errored:
203 raise self.engine_client.dead_error
204 self.engine_client.check_admission(len(request.items))
205
206 # Get tokenizer
207 tokenizer = self.renderer.tokenizer
208 if tokenizer is None:
209 return self.create_error_response(
210 "Tokenizer not available. Cannot process generative scoring request."
211 )
212
213 # Validate label_token_ids
214 vocab_size = self.model_config.get_vocab_size()
215 for token_id in request.label_token_ids:
216 if token_id < 0 or token_id >= vocab_size:
217 return self.create_error_response(
218 f"label_token_id {token_id} is out of vocabulary range "
219 f"[0, {vocab_size}). Please provide valid token IDs."
220 )
221
222 if len(request.label_token_ids) == 0:
223 return self.create_error_response(
224 "label_token_ids must contain at least one token ID."
225 )
226
227 # Validate items
228 if len(request.items) == 0:
229 return self.create_error_response("items must contain at least one item.")
230
231 # Note: Mixed item types (string and token list) are validated by
232 # Pydantic at request parsing time, so we don't need to check here.
233
234 try:
235 lora_request = self._maybe_get_adapters(request) # type: ignore[arg-type]
236 except (ValueError, TypeError, RuntimeError) as e:
237 logger.exception("Error preparing request components")
238 return self.create_error_response(e)
239
240 base_id = self._base_request_id(raw_request, default=request.request_id)
241 request_id = f"generative-scoring-{base_id}"
242 created_time = int(time.time())
243
244 # Build prompts for each item
245 try:
246 engine_inputs, prompt_token_counts = await self._build_prompts(
247 request, tokenizer, self.model_config.max_model_len
248 )
249 except (ValueError, TypeError) as e:
250 logger.exception("Error building prompts")
251 return self.create_error_response(e)
252
253 # Create sampling params for scoring
254 # We use max_tokens=1 with logprob_token_ids to efficiently get
255 # logprobs for only the specified label tokens (not full vocab)
256 # Note: temperature/top_k/top_p don't affect logprobs - they only
257 # affect the sampling distribution. Logprobs are computed from raw
258 # logits via log_softmax before any sampling transformations.
259 sampling_params = SamplingParams(
260 max_tokens=1,
261 logprobs=len(request.label_token_ids),
262 logprob_token_ids=request.label_token_ids,
263 n=1,
264 )
265
266 # Get trace headers
267 trace_headers = (
268 None
269 if raw_request is None
270 else await self._get_trace_headers(raw_request.headers)
271 )
272
273 # Schedule requests for all inputs
274 generators: list[AsyncGenerator[RequestOutput, None]] = []
275 for i, engine_input in enumerate(engine_inputs):
276 request_id_item = f"{request_id}-{i}"
277
278 self._log_inputs(
279 request_id_item,
280 engine_input,
281 params=sampling_params,
282 lora_request=lora_request,
283 )
284
285 generator = self.engine_client.generate(
286 engine_input,
287 sampling_params,
288 request_id_item,
289 lora_request=lora_request,
290 trace_headers=trace_headers,
291 priority=request.priority,
292 )
293 generators.append(generator)
294
295 # Collect results
296 result_generator = merge_async_iterators(*generators)
297 results: list[RequestOutput | None] = [None] * len(engine_inputs)
298
299 try:
300 async for i, res in result_generator:
301 results[i] = res
302 except asyncio.CancelledError:
303 return self.create_error_response("Client disconnected")
304 except Exception as e:
305 logger.exception("Error during generation")
306 return self.create_error_response(e)
307
308 # Process results to extract label token probabilities
309 item_results: list[GenerativeScoringItemResult] = []
310 total_prompt_tokens = 0
311 total_completion_tokens = 0
312
313 for i, result in enumerate(results):
314 if result is None:
315 return self.create_error_response(
316 f"Failed to generate result for item {i}"
317 )
318
319 # Check for errors
320 if result.outputs and result.outputs[0].finish_reason == "error":
321 return self.create_error_response(f"Generation error for item {i}")
322
323 # Get logprobs from the generated token
324 if not result.outputs or len(result.outputs) == 0:
325 return self.create_error_response(f"No output generated for item {i}")
326
327 output = result.outputs[0]
328 if output.logprobs is None or len(output.logprobs) == 0:
329 return self.create_error_response(
330 f"No logprobs available for item {i}. "
331 "This might indicate an issue with logprobs configuration."
332 )
333
334 # The logprobs dict maps token_id -> Logprob object
335 # For logprobs=-1, this contains all vocab tokens
336 logprobs_dict = output.logprobs[0]
337
338 # Extract logprobs for label tokens
339 label_logprobs: dict[int, float] = {}
340 missing_tokens = []
341 for token_id in request.label_token_ids:
342 if token_id in logprobs_dict:
343 label_logprobs[token_id] = logprobs_dict[token_id].logprob
344 else:
345 missing_tokens.append(token_id)
346
347 if missing_tokens:
348 return self.create_error_response(
349 f"Token IDs {missing_tokens} not found in logprobs for item {i}. "
350 "This might indicate the tokens are outside the model's vocabulary."
351 )
352
353 # Compute probabilities based on apply_softmax setting
354 token_probs = self._compute_probabilities(
355 label_logprobs,
356 apply_softmax=request.apply_softmax,
357 )
358
359 # Use the first label token's probability as the score
360 first_label_id = request.label_token_ids[0]
361 score = token_probs[first_label_id]
362
363 item_results.append(
364 GenerativeScoringItemResult(
365 index=i,
366 score=score,
367 )
368 )
369
370 # Update token counts
371 total_prompt_tokens += prompt_token_counts[i]
372 total_completion_tokens += len(output.token_ids)
373
374 # Build response
375 model_name = self.models.model_name(lora_request)
376 response = GenerativeScoringResponse(
377 id=request_id,
378 created=created_time,
379 model=model_name,
380 data=item_results,
381 usage=UsageInfo(
382 prompt_tokens=total_prompt_tokens,
383 total_tokens=total_prompt_tokens + total_completion_tokens,
384 completion_tokens=total_completion_tokens,
385 ),
386 )
387
388 return response
389
390 async def _build_prompts(
391 self,
392 request: GenerativeScoringRequest,
393 tokenizer: TokenizerLike,
394 max_model_len: int,
395 ) -> tuple[list[EngineInput], list[int]]:
396 """Build prompts by concatenating query and items.
397
398 Uses the Renderer's tokenizer to tokenize text inputs, then
399 creates EngineInput via tokens_input() for engine consumption.
400
401 Args:
402 request: The request containing query, items, and settings.
403 tokenizer: The tokenizer to use.
404 max_model_len: Maximum model context length for truncation.
405
406 Returns:
407 Tuple of (list of EngineInput, list of prompt token counts).
408
409 """
410 # Tokenize query if it's a string
411 if isinstance(request.query, str):
412 query_token_ids = tokenizer.encode(
413 request.query,
414 add_special_tokens=request.add_special_tokens,
415 )
416 else:
417 query_token_ids = request.query
418
419 engine_inputs: list[EngineInput] = []
420 prompt_token_counts: list[int] = []
421
422 for item in request.items:
423 # Tokenize item if it's a string
424 if isinstance(item, str):
425 # Don't add special tokens for items to avoid duplicate BOS/EOS
426 item_token_ids = tokenizer.encode(
427 item,
428 add_special_tokens=False,
429 )
430 else:
431 item_token_ids = item
432
433 # Concatenate based on item_first setting
434 if request.item_first:
435 prompt_token_ids = item_token_ids + query_token_ids
436 else:
437 prompt_token_ids = query_token_ids + item_token_ids
438
439 # Truncate to max_model_len - 1 to leave room for 1 output token
440 max_prompt_len = max_model_len - 1
441 if len(prompt_token_ids) > max_prompt_len:
442 prompt_token_ids = prompt_token_ids[:max_prompt_len]
443
444 engine_inputs.append(tokens_input(prompt_token_ids))
445 prompt_token_counts.append(len(prompt_token_ids))
446
447 return engine_inputs, prompt_token_counts
448
449 def _compute_probabilities(
450 self,
451 label_logprobs: dict[int, float],
452 apply_softmax: bool,
453 ) -> dict[int, float]:
454 """Compute probabilities from logprobs.
455
456 Args:
457 label_logprobs: Dictionary mapping token_id to logprob.
458 apply_softmax: If True, normalize over only the label tokens.
459 If False, return true model probabilities (exp(logprob)).
460
461 Returns:
462 Dictionary mapping token_id to probability.
463
464 """
465 if apply_softmax:
466 # Normalize over only the label tokens (subset softmax)
467 # softmax(gathered_logits) over the subset
468 logprobs_list = list(label_logprobs.values())
469 max_logprob = max(logprobs_list)
470
471 # Compute exp(logprob - max) for numerical stability
472 exp_values = {
473 token_id: math.exp(logprob - max_logprob)
474 for token_id, logprob in label_logprobs.items()
475 }
476 sum_exp = sum(exp_values.values())
477
478 return {
479 token_id: exp_val / sum_exp for token_id, exp_val in exp_values.items()
480 }
481 else:
482 # Return true model probabilities
483 # Since logprobs are already log(softmax(logits)),
484 # we just need to exp() them
485 return {
486 token_id: math.exp(logprob)
487 for token_id, logprob in label_logprobs.items()
488 }
489
490 async def _get_trace_headers(
491 self,
492 headers: Mapping[str, str],
493 ) -> Mapping[str, str] | None:
494 """Extract trace headers from request headers."""
495 if not contains_trace_headers(headers):
496 return None
497
498 if not await self.engine_client.is_tracing_enabled():
499 log_tracing_disabled_warning()
500 return None
501
502 return extract_trace_headers(headers)