Text Generation
Transformers
Safetensors
English
qwen3
hybrid-reasoning
latent-reasoning
agent
tool-use
exl3
vllm
text-generation-inference
quantization
experimental
rtx3090
ampere
conversational
5-bit
Instructions to use JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental") model = AutoModelForCausalLM.from_pretrained("JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental", device_map="auto") messages = [ {"role": "user", "content": "Who are you?"}, ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental
- SGLang
How to use JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental with Docker Model Runner:
docker model run hf.co/JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental
Upload folder using huggingface_hub
Browse files- serving/backend/README.md +29 -0
- serving/backend/backend/__init__.py +57 -0
- serving/backend/backend/hybridlink.py +29 -0
- serving/backend/backend/model.py +96 -0
- serving/backend/pyproject.toml +18 -0
- serving/fork/v1/core/kv_cache_manager.py +960 -0
- serving/fork/v1/core/sched/output.py +336 -0
- serving/fork/v1/core/sched/scheduler.py +0 -0
- serving/fork/v1/request.py +417 -0
- serving/fork/v1/worker/gpu_model_runner.py +0 -0
- serving/orchestrator/README.md +58 -0
- serving/orchestrator/__init__.py +41 -0
- serving/orchestrator/orchestrator.py +350 -0
- serving/orchestrator/pyproject.toml +15 -0
- serving/orchestrator/tools.py +134 -0
- serving/processor/__init__.py +62 -0
- serving/processor/processor.py +370 -0
- serving/processor/pyproject.toml +24 -0
- serving/processor/state_machine.py +410 -0
- serving/processor/test_blocked_masks.py +354 -0
- serving/serve.py +176 -0
- serving/setup.sh +51 -0
serving/backend/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HybridAgent vLLM backend (T3)
|
| 2 |
+
|
| 3 |
+
vLLM plugin that serves `togethercomputer/HybridAgent-8B-Agentic`
|
| 4 |
+
(Qwen3-8B-Base backbone + HybridLink latent adapter) inside vLLM 0.30 on the
|
| 5 |
+
forked core (`/opt/hybridagent-vllm/vllm`, branch `hybridagent-latent-fork`).
|
| 6 |
+
|
| 7 |
+
- `backend/model.py` — `HybridAgentForCausalLM`: wraps the in-tree
|
| 8 |
+
`Qwen3ForCausalLM` and adds the `HybridLink` MLP. Provides the
|
| 9 |
+
`project_latent(hidden)` hook the fork's `gpu_model_runner` calls to feed
|
| 10 |
+
`R(last_hidden)` as `inputs_embeds` on zero-token latent steps. Backbone
|
| 11 |
+
weights load through the stock Qwen3 path, so an EXL3 checkpoint
|
| 12 |
+
(`quant_method: "exl3"`, OrcaSAQ2 linear layers) loads as a stock Qwen3
|
| 13 |
+
would. `adapter.pt` loads with `torch.load(weights_only=True)` + strict
|
| 14 |
+
`load_state_dict`.
|
| 15 |
+
- `backend/hybridlink.py` — verbatim port of `HybridLink` from
|
| 16 |
+
togethercomputer/HybridAgent (parameter names match `adapter.pt`).
|
| 17 |
+
- `backend/__init__.py` — `register()` entry point
|
| 18 |
+
(`vllm.general_plugins`): maps the checkpoint arch string
|
| 19 |
+
(`Qwen3ForCausalLM`, per the released config.json — re-confirm at T7) to
|
| 20 |
+
`HybridAgentForCausalLM` and arms the OrcaSAQ2 `exl3` quant config when
|
| 21 |
+
installed.
|
| 22 |
+
|
| 23 |
+
Install into the serving venv to activate:
|
| 24 |
+
|
| 25 |
+
pip install -e /opt/hybridagent-vllm/backend
|
| 26 |
+
|
| 27 |
+
Synthetic validation (no model download):
|
| 28 |
+
|
| 29 |
+
/opt/hybridagent-vllm/venv/bin/python /opt/hybridagent-vllm/poc/test_backend_synthetic.py
|
serving/backend/backend/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""vLLM plugin: HybridAgent backend (Qwen3 backbone + HybridLink latent MLP).
|
| 2 |
+
|
| 3 |
+
Wired through the `vllm.general_plugins` entry point (see pyproject.toml):
|
| 4 |
+
|
| 5 |
+
[project.entry-points."vllm.general_plugins"]
|
| 6 |
+
hybridagent_backend = "backend:register"
|
| 7 |
+
|
| 8 |
+
vLLM calls `register()` in every process that resolves model classes
|
| 9 |
+
(engine front-end and the registry's inspector subprocesses), so the
|
| 10 |
+
architecture used by HybridAgent checkpoints maps to the latent-capable
|
| 11 |
+
backend everywhere.
|
| 12 |
+
|
| 13 |
+
The arch string is the one in the released model's config.json
|
| 14 |
+
("architectures": ["Qwen3ForCausalLM"], verified against
|
| 15 |
+
togethercomputer/HybridAgent-8B-Agentic). Re-confirm it against the exact
|
| 16 |
+
checkpoint at T7. Registering under it intentionally shadows the in-tree
|
| 17 |
+
Qwen3 model in this isolated venv: the venv serves HybridAgent only.
|
| 18 |
+
"""
|
| 19 |
+
from .model import HybridAgentForCausalLM
|
| 20 |
+
|
| 21 |
+
ARCH = "Qwen3ForCausalLM"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def register() -> None:
|
| 25 |
+
"""Entry point: register the HybridAgent backend model architecture."""
|
| 26 |
+
from vllm.logger import init_logger
|
| 27 |
+
from vllm.model_executor.models.registry import ModelRegistry
|
| 28 |
+
|
| 29 |
+
logger = init_logger("vllm.hybridagent")
|
| 30 |
+
ModelRegistry.register_model(ARCH, "backend.model:HybridAgentForCausalLM")
|
| 31 |
+
logger.info(
|
| 32 |
+
"hybridagent backend armed: %s -> HybridAgentForCausalLM "
|
| 33 |
+
"(zero-token latent steps via the project_latent hook)",
|
| 34 |
+
ARCH,
|
| 35 |
+
)
|
| 36 |
+
_arm_exl3(logger)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _arm_exl3(logger) -> None:
|
| 40 |
+
"""Reuse the OrcaSAQ2 'exl3' quantization config when it is installed.
|
| 41 |
+
|
| 42 |
+
The backbone's EXL3 linear layers come from the existing OrcaSAQ2
|
| 43 |
+
plugin (config.json `quant_method: "exl3"`); this backend does not
|
| 44 |
+
reinvent them. Without it, only dense checkpoints can load here.
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
import orcasaq2
|
| 48 |
+
except ImportError:
|
| 49 |
+
logger.warning(
|
| 50 |
+
"hybridagent backend: orcasaq2 not installed in this environment; "
|
| 51 |
+
"EXL3 checkpoints cannot load here (dense checkpoints are unaffected)"
|
| 52 |
+
)
|
| 53 |
+
return
|
| 54 |
+
orcasaq2.register()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
__all__ = ["ARCH", "HybridAgentForCausalLM", "register"]
|
serving/backend/backend/hybridlink.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HybridLink: a lightweight residual adapter that maps a last-layer hidden
|
| 2 |
+
state back into the input-embedding space,
|
| 3 |
+
R(h) = post_ln(h + proj2(gelu(proj1(pre_ln(h)))))
|
| 4 |
+
|
| 5 |
+
Ported verbatim from togethercomputer/HybridAgent (`hybrid_agent/hybridlink.py`
|
| 6 |
+
@ HEAD 1b41cebb). The parameter names match the released checkpoints
|
| 7 |
+
(`adapter.pt`), so the file loads with a strict `load_state_dict`.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
|
| 14 |
+
# Tag stored in `adapter_config.json` next to the HybridLink weights.
|
| 15 |
+
HYBRIDLINK_CONFIG = {"adapter_type": "ln_res_adapter"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class HybridLink(nn.Module):
|
| 19 |
+
def __init__(self, hidden_size: int) -> None:
|
| 20 |
+
super().__init__()
|
| 21 |
+
self.proj1 = nn.Linear(hidden_size, hidden_size)
|
| 22 |
+
self.act = nn.GELU()
|
| 23 |
+
self.proj2 = nn.Linear(hidden_size, hidden_size)
|
| 24 |
+
self.pre_ln = nn.LayerNorm(hidden_size)
|
| 25 |
+
self.post_ln = nn.LayerNorm(hidden_size)
|
| 26 |
+
|
| 27 |
+
def forward(self, h: torch.Tensor) -> torch.Tensor:
|
| 28 |
+
out = self.proj2(self.act(self.proj1(self.pre_ln(h))))
|
| 29 |
+
return self.post_ln(h + out)
|
serving/backend/backend/model.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""vLLM backend for HybridAgent: Qwen3 backbone + HybridLink latent adapter.
|
| 2 |
+
|
| 3 |
+
Wraps vLLM's in-tree Qwen3ForCausalLM and adds the HybridLink MLP as a
|
| 4 |
+
submodule. The forked gpu_model_runner (T2) detects latent-capable models
|
| 5 |
+
via the `project_latent` hook and feeds R(last_hidden) as inputs_embeds for
|
| 6 |
+
zero-token latent steps; this class provides that hook and keeps the stock
|
| 7 |
+
Qwen3 forward/logits/weight-loading contract everywhere else.
|
| 8 |
+
|
| 9 |
+
Weight loading:
|
| 10 |
+
* Backbone: the stock Qwen3 path (AutoWeightsLoader over the checkpoint's
|
| 11 |
+
safetensors). Because the in-tree layers are built from
|
| 12 |
+
`vllm_config.quant_config`, an EXL3 checkpoint (config.json
|
| 13 |
+
`quant_method: "exl3"`, served by the OrcaSAQ2 linear layers) loads
|
| 14 |
+
exactly as a stock Qwen3 would -- this backend does not reinvent EXL3.
|
| 15 |
+
* HybridLink: the separate `adapter.pt` file next to the checkpoint,
|
| 16 |
+
loaded with `torch.load(..., weights_only=True)` + strict
|
| 17 |
+
`load_state_dict`, mirroring HybridAgentModel.from_pretrained.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
from vllm.config import VllmConfig
|
| 27 |
+
from vllm.logger import init_logger
|
| 28 |
+
from vllm.model_executor.models.qwen3 import Qwen3ForCausalLM
|
| 29 |
+
|
| 30 |
+
from .hybridlink import HYBRIDLINK_CONFIG, HybridLink
|
| 31 |
+
|
| 32 |
+
logger = init_logger(__name__)
|
| 33 |
+
|
| 34 |
+
HYBRIDLINK_WEIGHTS = "adapter.pt"
|
| 35 |
+
HYBRIDLINK_CONFIG_FILE = "adapter_config.json"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class HybridAgentForCausalLM(Qwen3ForCausalLM):
|
| 39 |
+
"""Qwen3ForCausalLM + HybridLink, for the forked zero-token latent runner."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
|
| 42 |
+
super().__init__(vllm_config=vllm_config, prefix=prefix)
|
| 43 |
+
hidden_size = self.config.hidden_size
|
| 44 |
+
# S4: the HybridLink MLP stays BF16 regardless of backbone dtype.
|
| 45 |
+
self.hybridlink = HybridLink(hidden_size).to(torch.bfloat16)
|
| 46 |
+
self._compute_dtype = vllm_config.model_config.dtype
|
| 47 |
+
|
| 48 |
+
def project_latent(self, hidden: torch.Tensor) -> torch.Tensor:
|
| 49 |
+
"""R(h): the hook the forked gpu_model_runner calls for latent steps.
|
| 50 |
+
|
| 51 |
+
Accepts a [D] or [B, D] last-layer hidden state and returns the same
|
| 52 |
+
shape in the model's compute dtype, ready to be consumed as
|
| 53 |
+
inputs_embeds for the zero-token latent position.
|
| 54 |
+
"""
|
| 55 |
+
x = hidden
|
| 56 |
+
link_dtype = next(self.hybridlink.parameters()).dtype
|
| 57 |
+
if x.dtype != link_dtype:
|
| 58 |
+
x = x.to(link_dtype)
|
| 59 |
+
# no_grad: the hook is called inside execute_model's inference_mode,
|
| 60 |
+
# but this keeps it correct from any context (LayerNorm on inference
|
| 61 |
+
# tensors fails under an autograd-tracked mode).
|
| 62 |
+
with torch.no_grad():
|
| 63 |
+
out = self.hybridlink(x)
|
| 64 |
+
if out.dtype != self._compute_dtype:
|
| 65 |
+
out = out.to(self._compute_dtype)
|
| 66 |
+
return out
|
| 67 |
+
|
| 68 |
+
def load_weights(self, weights):
|
| 69 |
+
loaded = super().load_weights(weights)
|
| 70 |
+
self._load_hybridlink()
|
| 71 |
+
# The MLP weights come from adapter.pt, not the checkpoint stream;
|
| 72 |
+
# report them as loaded so the loader's completeness tracker passes.
|
| 73 |
+
loaded.update(f"hybridlink.{name}" for name, _ in self.hybridlink.named_parameters())
|
| 74 |
+
return loaded
|
| 75 |
+
|
| 76 |
+
def _load_hybridlink(self) -> None:
|
| 77 |
+
"""Strict-load the latent adapter from adapter.pt next to the checkpoint."""
|
| 78 |
+
model_dir = Path(self.vllm_config.model_config.model)
|
| 79 |
+
weights_path = model_dir / HYBRIDLINK_WEIGHTS
|
| 80 |
+
if not weights_path.is_file():
|
| 81 |
+
raise FileNotFoundError(
|
| 82 |
+
f"{HYBRIDLINK_WEIGHTS} not found in {model_dir}; "
|
| 83 |
+
"HybridAgent serving requires the latent adapter"
|
| 84 |
+
)
|
| 85 |
+
config_path = model_dir / HYBRIDLINK_CONFIG_FILE
|
| 86 |
+
if config_path.is_file():
|
| 87 |
+
tag = json.loads(config_path.read_text(encoding="utf-8")).get(
|
| 88 |
+
"adapter_type"
|
| 89 |
+
)
|
| 90 |
+
if tag != HYBRIDLINK_CONFIG["adapter_type"]:
|
| 91 |
+
raise ValueError(
|
| 92 |
+
f"unsupported HybridLink config {tag!r} in {config_path}"
|
| 93 |
+
)
|
| 94 |
+
state = torch.load(str(weights_path), map_location="cpu", weights_only=True)
|
| 95 |
+
self.hybridlink.load_state_dict(state, strict=True)
|
| 96 |
+
logger.info("hybridagent backend: HybridLink loaded from %s", weights_path)
|
serving/backend/pyproject.toml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "hybridagent-vllm-backend"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "vLLM backend for HybridAgent: Qwen3ForCausalLM backbone + HybridLink latent MLP (zero-token latent steps)"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
# vllm and torch are intentionally not declared: they come from the serving
|
| 11 |
+
# environment, mirroring orcasaq2-kernel.
|
| 12 |
+
dependencies = []
|
| 13 |
+
|
| 14 |
+
[project.entry-points."vllm.general_plugins"]
|
| 15 |
+
hybridagent_backend = "backend:register"
|
| 16 |
+
|
| 17 |
+
[tool.setuptools]
|
| 18 |
+
packages = ["backend"]
|
serving/fork/v1/core/kv_cache_manager.py
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
| 3 |
+
|
| 4 |
+
import itertools
|
| 5 |
+
from collections.abc import Sequence
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Literal, overload
|
| 8 |
+
|
| 9 |
+
from vllm.distributed.kv_events import BlockStored, KVCacheEvent
|
| 10 |
+
from vllm.logger import init_logger
|
| 11 |
+
from vllm.utils.math_utils import cdiv
|
| 12 |
+
from vllm.v1.core.kv_cache_coordinator import (
|
| 13 |
+
HybridKVCacheCoordinator,
|
| 14 |
+
get_kv_cache_coordinator,
|
| 15 |
+
)
|
| 16 |
+
from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
|
| 17 |
+
from vllm.v1.core.kv_cache_utils import KVCacheBlock, KVCacheBlockCopy
|
| 18 |
+
from vllm.v1.core.single_type_kv_cache_manager import MambaManager
|
| 19 |
+
from vllm.v1.kv_cache_interface import (
|
| 20 |
+
AttentionSpec,
|
| 21 |
+
CrossAttentionSpec,
|
| 22 |
+
EncoderOnlyAttentionSpec,
|
| 23 |
+
KVCacheConfig,
|
| 24 |
+
MambaSpec,
|
| 25 |
+
get_kv_cache_spec_kind,
|
| 26 |
+
get_kv_cache_spec_sliding_window,
|
| 27 |
+
)
|
| 28 |
+
from vllm.v1.metrics.stats import PrefixCacheStats
|
| 29 |
+
from vllm.v1.request import Request, RequestStatus
|
| 30 |
+
|
| 31 |
+
logger = init_logger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class KVCacheBlocks:
|
| 36 |
+
"""
|
| 37 |
+
The allocation result of KVCacheManager, work as the interface between
|
| 38 |
+
Scheduler and KVCacheManager, to hide KVCacheManager's internal data
|
| 39 |
+
structure from the Scheduler.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
blocks: tuple[Sequence[KVCacheBlock], ...]
|
| 43 |
+
"""
|
| 44 |
+
`blocks[i][j]` refers to the i-th kv_cache_group
|
| 45 |
+
and the j-th block of tokens.We don't use block of
|
| 46 |
+
tokens as the outer dimension because it assumes all
|
| 47 |
+
kv_cache_groups have the same number of blocks, which is true for now but
|
| 48 |
+
will be broken if we want to give different block_size to different
|
| 49 |
+
kv_cache_groups in the future.
|
| 50 |
+
|
| 51 |
+
Each single type KVCacheBlocks could be represented as:
|
| 52 |
+
- list[KVCacheBlock] for more than one KVCacheBlock
|
| 53 |
+
- an empty tuple for requests without KVCacheBlock
|
| 54 |
+
(a precomputed KVCacheBlocks is in KVCacheManager to avoid GC overhead)
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
def __add__(self, other: "KVCacheBlocks") -> "KVCacheBlocks":
|
| 58 |
+
"""Adds two KVCacheBlocks instances."""
|
| 59 |
+
return KVCacheBlocks(
|
| 60 |
+
tuple(
|
| 61 |
+
list(itertools.chain(blk1, blk2))
|
| 62 |
+
for blk1, blk2 in zip(self.blocks, other.blocks)
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
@overload
|
| 67 |
+
def get_block_ids(
|
| 68 |
+
self,
|
| 69 |
+
allow_none: Literal[False] = False,
|
| 70 |
+
*,
|
| 71 |
+
group_ids: Sequence[int] | None = None,
|
| 72 |
+
) -> tuple[list[int], ...]: ...
|
| 73 |
+
|
| 74 |
+
@overload
|
| 75 |
+
def get_block_ids(
|
| 76 |
+
self,
|
| 77 |
+
allow_none: Literal[True] = True,
|
| 78 |
+
*,
|
| 79 |
+
group_ids: Sequence[int] | None = None,
|
| 80 |
+
) -> tuple[list[int], ...] | None: ...
|
| 81 |
+
|
| 82 |
+
def get_block_ids(
|
| 83 |
+
self,
|
| 84 |
+
allow_none: bool = False,
|
| 85 |
+
*,
|
| 86 |
+
group_ids: Sequence[int] | None = None,
|
| 87 |
+
) -> tuple[list[int], ...] | None:
|
| 88 |
+
"""
|
| 89 |
+
Converts the KVCacheBlocks instance to block_ids.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
allow_none: Return None when every selected group is empty.
|
| 93 |
+
group_ids: KV cache groups to include. Includes all groups by default.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
tuple[list[int], ...]: A tuple of lists where:
|
| 97 |
+
- the outer tuple corresponds to KV cache groups
|
| 98 |
+
- each inner list contains the block_ids of the blocks in that
|
| 99 |
+
group
|
| 100 |
+
"""
|
| 101 |
+
groups = (
|
| 102 |
+
self.blocks
|
| 103 |
+
if group_ids is None
|
| 104 |
+
else tuple(self.blocks[group_id] for group_id in group_ids)
|
| 105 |
+
)
|
| 106 |
+
if allow_none and all(len(group) == 0 for group in groups):
|
| 107 |
+
return None
|
| 108 |
+
return tuple([blk.block_id for blk in group] for group in groups)
|
| 109 |
+
|
| 110 |
+
def get_unhashed_block_ids(self) -> list[int]:
|
| 111 |
+
"""Get block_ids of unhashed blocks from KVCacheBlocks instance."""
|
| 112 |
+
assert len(self.blocks) == 1, "Only one group is supported"
|
| 113 |
+
return [block.block_id for block in self.blocks[0] if block.block_hash is None]
|
| 114 |
+
|
| 115 |
+
def get_unhashed_block_ids_all_groups(self) -> list[list[int]]:
|
| 116 |
+
"""Get block_ids of unhashed blocks from KVCacheBlocks instance."""
|
| 117 |
+
# Skip padding blocks.
|
| 118 |
+
return [
|
| 119 |
+
[
|
| 120 |
+
block.block_id
|
| 121 |
+
for block in group
|
| 122 |
+
if block.block_hash is None and not block.is_null
|
| 123 |
+
]
|
| 124 |
+
for group in self.blocks
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
def new_empty(self) -> "KVCacheBlocks":
|
| 128 |
+
"""
|
| 129 |
+
Creates a new KVCacheBlocks instance with no blocks.
|
| 130 |
+
"""
|
| 131 |
+
return KVCacheBlocks(tuple(() for _ in range(len(self.blocks))))
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class KVCacheManager:
|
| 135 |
+
def __init__(
|
| 136 |
+
self,
|
| 137 |
+
kv_cache_config: KVCacheConfig,
|
| 138 |
+
max_model_len: int,
|
| 139 |
+
scheduler_block_size: int,
|
| 140 |
+
hash_block_size: int,
|
| 141 |
+
max_in_flight_tokens: int | None = None,
|
| 142 |
+
enable_caching: bool = True,
|
| 143 |
+
use_eagle: bool = False,
|
| 144 |
+
num_prefill_lookahead: int = 0,
|
| 145 |
+
log_stats: bool = False,
|
| 146 |
+
enable_kv_cache_events: bool = False,
|
| 147 |
+
dcp_world_size: int = 1,
|
| 148 |
+
pcp_world_size: int = 1,
|
| 149 |
+
metrics_collector: KVCacheMetricsCollector | None = None,
|
| 150 |
+
watermark: float = 0.0,
|
| 151 |
+
enable_mamba_fine_grained_prefix_cache: bool = False,
|
| 152 |
+
) -> None:
|
| 153 |
+
self.max_model_len = max_model_len
|
| 154 |
+
# When unset, fall back to `max_model_len` so the recycling-aware cap
|
| 155 |
+
# collapses to the prior (uncapped) admission behavior. The scheduler
|
| 156 |
+
# always supplies the real value at runtime.
|
| 157 |
+
if max_in_flight_tokens is None:
|
| 158 |
+
max_in_flight_tokens = max_model_len
|
| 159 |
+
|
| 160 |
+
self.enable_caching = enable_caching
|
| 161 |
+
self.enable_kv_cache_events = enable_kv_cache_events
|
| 162 |
+
self.use_eagle = use_eagle
|
| 163 |
+
self.log_stats = log_stats
|
| 164 |
+
self.metrics_collector = metrics_collector
|
| 165 |
+
# FIXME: make prefix cache stats conditional on log_stats. We still need
|
| 166 |
+
# this comment because when the log stats is enabled there are still
|
| 167 |
+
# potential configs we could expose in the future.
|
| 168 |
+
self.prefix_cache_stats = PrefixCacheStats() if log_stats else None
|
| 169 |
+
|
| 170 |
+
self.coordinator = get_kv_cache_coordinator(
|
| 171 |
+
kv_cache_config=kv_cache_config,
|
| 172 |
+
max_model_len=self.max_model_len,
|
| 173 |
+
max_in_flight_tokens=max_in_flight_tokens,
|
| 174 |
+
use_eagle=self.use_eagle,
|
| 175 |
+
enable_caching=self.enable_caching,
|
| 176 |
+
enable_kv_cache_events=enable_kv_cache_events,
|
| 177 |
+
dcp_world_size=dcp_world_size,
|
| 178 |
+
pcp_world_size=pcp_world_size,
|
| 179 |
+
scheduler_block_size=scheduler_block_size,
|
| 180 |
+
hash_block_size=hash_block_size,
|
| 181 |
+
metrics_collector=self.metrics_collector,
|
| 182 |
+
num_prefill_lookahead=num_prefill_lookahead,
|
| 183 |
+
)
|
| 184 |
+
# One predicate, read by both sides of the feature, so the scheduler
|
| 185 |
+
# cannot end a chunk at a junction the manager would refuse -- a refused
|
| 186 |
+
# junction costs a forward pass and displaces the block-boundary stop.
|
| 187 |
+
# Multi-module MTP is excluded because ``cache_blocks`` then hands the
|
| 188 |
+
# manager ``num_computed - num_reprefillable`` rather than the chunk end.
|
| 189 |
+
self.mamba_fine_grained_prefix_cache = (
|
| 190 |
+
enable_mamba_fine_grained_prefix_cache
|
| 191 |
+
and bool(self.coordinator.eagle_group_ids)
|
| 192 |
+
and self.coordinator.enable_partial_hash_hits
|
| 193 |
+
and self.coordinator.num_reprefillable_tokens == 0
|
| 194 |
+
)
|
| 195 |
+
if self.mamba_fine_grained_prefix_cache:
|
| 196 |
+
for manager in self.coordinator.single_type_managers:
|
| 197 |
+
if isinstance(manager, MambaManager):
|
| 198 |
+
manager.fine_grained_prefix_cache = True
|
| 199 |
+
self.num_kv_cache_groups = len(kv_cache_config.kv_cache_groups)
|
| 200 |
+
self.block_pool = self.coordinator.block_pool
|
| 201 |
+
self.retained_hit_group_ids = tuple(
|
| 202 |
+
manager.kv_cache_group_id
|
| 203 |
+
for manager in self.coordinator.single_type_managers
|
| 204 |
+
if manager.retains_longer_hit
|
| 205 |
+
)
|
| 206 |
+
self.kv_cache_config = kv_cache_config
|
| 207 |
+
|
| 208 |
+
# Watermark: minimum number of KV cache blocks to keep free when
|
| 209 |
+
# admitting waiting/preempted requests, to avoid frequent preemptions.
|
| 210 |
+
assert watermark >= 0.0, "watermark must be non-negative"
|
| 211 |
+
self.watermark_blocks = int(watermark * kv_cache_config.num_blocks)
|
| 212 |
+
self.kv_cache_event_metadata = tuple(
|
| 213 |
+
(
|
| 214 |
+
get_kv_cache_spec_kind(group.kv_cache_spec).value,
|
| 215 |
+
get_kv_cache_spec_sliding_window(group.kv_cache_spec),
|
| 216 |
+
)
|
| 217 |
+
for group in kv_cache_config.kv_cache_groups
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Pre-constructed KVCacheBlocks with no blocks, callers should use this
|
| 221 |
+
# via create_kv_cache_blocks instead of creating new ones to avoid GC
|
| 222 |
+
# overhead.
|
| 223 |
+
#
|
| 224 |
+
# We use nested tuples to ensure the empty KVCacheBlocks is immutable.
|
| 225 |
+
self.empty_kv_cache_blocks = KVCacheBlocks(
|
| 226 |
+
tuple(() for _ in range(self.num_kv_cache_groups))
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
@property
|
| 230 |
+
def usage(self) -> float:
|
| 231 |
+
"""Get the KV cache usage.
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
The KV cache usage (between 0.0 and 1.0).
|
| 235 |
+
"""
|
| 236 |
+
return self.block_pool.get_usage()
|
| 237 |
+
|
| 238 |
+
def make_prefix_cache_stats(self) -> PrefixCacheStats | None:
|
| 239 |
+
"""Get (and reset) the prefix cache stats.
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
The current prefix caching stats, or None if logging is disabled.
|
| 243 |
+
"""
|
| 244 |
+
if not self.log_stats:
|
| 245 |
+
return None
|
| 246 |
+
stats = self.prefix_cache_stats
|
| 247 |
+
self.prefix_cache_stats = PrefixCacheStats()
|
| 248 |
+
return stats
|
| 249 |
+
|
| 250 |
+
def prefix_cache_lookup_enabled(self, request: Request) -> bool:
|
| 251 |
+
"""Whether a local prefix cache lookup may be run for this request."""
|
| 252 |
+
return self.enable_caching and not request.skip_reading_prefix_cache
|
| 253 |
+
|
| 254 |
+
def record_prefix_cache_stats(self, request: Request, num_hits: int) -> None:
|
| 255 |
+
# Don't count a request that skipped the cache lookup.
|
| 256 |
+
if not self.log_stats or not self.prefix_cache_lookup_enabled(request):
|
| 257 |
+
return
|
| 258 |
+
assert self.prefix_cache_stats is not None
|
| 259 |
+
self.prefix_cache_stats.record(
|
| 260 |
+
num_tokens=request.num_tokens,
|
| 261 |
+
num_hits=num_hits,
|
| 262 |
+
preempted=request.num_preemptions > 0,
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int, int]:
|
| 266 |
+
"""Get the computed (cached) blocks for the request.
|
| 267 |
+
Note that the computed blocks must be full.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
request: The request to get the computed blocks.
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
A tuple containing:
|
| 274 |
+
- A list of blocks that are computed for the request.
|
| 275 |
+
- The number of computed tokens.
|
| 276 |
+
- ``shared_prefix_boundary``: the block-aligned token position of
|
| 277 |
+
a shared prefix that a sparse-retention group (Mamba / sliding
|
| 278 |
+
window) has not cached yet (Marconi-style APC), or 0 if none.
|
| 279 |
+
Pinned so sparse prefix-cache retention does not drop
|
| 280 |
+
the junction and defeat cross-request reuse.
|
| 281 |
+
"""
|
| 282 |
+
# We skip finding the prefix cache hit when prefix caching is
|
| 283 |
+
# disabled or the request is marked as skipping kv cache read
|
| 284 |
+
# (which happens when the request requires prompt logprobs
|
| 285 |
+
# or calls a pooling model with all pooling).
|
| 286 |
+
if not self.prefix_cache_lookup_enabled(request):
|
| 287 |
+
return self.empty_kv_cache_blocks, 0, 0
|
| 288 |
+
|
| 289 |
+
# NOTE: When all tokens hit the cache, we must recompute the last token
|
| 290 |
+
# to obtain logits. Thus, set max_cache_hit_length to prompt_length - 1.
|
| 291 |
+
# This can trigger recomputation of an entire block, rather than just
|
| 292 |
+
# the single last token, because allocate_slots() requires
|
| 293 |
+
# num_computed_tokens to be block-size aligned. Removing this limitation
|
| 294 |
+
# could slightly improve performance in the future.
|
| 295 |
+
max_cache_hit_length = request.num_tokens - 1
|
| 296 |
+
computed_blocks, num_new_computed_tokens, num_uncached = (
|
| 297 |
+
self.coordinator.find_longest_cache_hit(
|
| 298 |
+
request.block_hashes, max_cache_hit_length
|
| 299 |
+
)
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# When kv_cache_report_mode is "full", emit BlockStored events
|
| 303 |
+
# for the reused prefix cache blocks so that external consumers
|
| 304 |
+
# (e.g. gateway) can learn about them.
|
| 305 |
+
if (
|
| 306 |
+
num_new_computed_tokens > 0
|
| 307 |
+
and self.enable_kv_cache_events
|
| 308 |
+
and getattr(request, "kv_cache_report_mode", "incremental") == "full"
|
| 309 |
+
):
|
| 310 |
+
self.coordinator.emit_cached_block_events(request, computed_blocks)
|
| 311 |
+
|
| 312 |
+
# The junction to pin is where the lagging sparse-retention group stops
|
| 313 |
+
# (``num_new_computed_tokens``) plus the uncached shared prefix -- i.e.
|
| 314 |
+
# the longest single-group hit. Sub-block gaps are left to the mask,
|
| 315 |
+
# which floors to the alignment boundary (a no-op there).
|
| 316 |
+
shared_prefix_boundary = (
|
| 317 |
+
num_new_computed_tokens + num_uncached if num_uncached else 0
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
blocks = self.create_kv_cache_blocks(computed_blocks)
|
| 321 |
+
return blocks, num_new_computed_tokens, shared_prefix_boundary
|
| 322 |
+
|
| 323 |
+
def get_computed_blocks_for_connector(
|
| 324 |
+
self, request: Request
|
| 325 |
+
) -> tuple[KVCacheBlocks, int, int, bool]:
|
| 326 |
+
"""Local prefix-cache lookup for a request scheduled with a KV connector.
|
| 327 |
+
|
| 328 |
+
Hybrid (Mamba + full-attention) models can have per-group prefix hits
|
| 329 |
+
diverge under block pressure: the full-attention tail may be evicted
|
| 330 |
+
while a deeper Mamba state survives, or vice versa. Report the
|
| 331 |
+
full-attention hit as the local prefix - the connector transfers the
|
| 332 |
+
remaining suffix and the Mamba state is transferred unconditionally by
|
| 333 |
+
nixl's ``_apply_prefix_caching`` - and flag when that hit ran deeper
|
| 334 |
+
than a lagging group. Such a hit only has a valid Mamba state at its
|
| 335 |
+
boundary if the connector supplies it, so the caller must fall back to
|
| 336 |
+
``get_computed_blocks`` to reconcile when no external tokens are found.
|
| 337 |
+
|
| 338 |
+
Non-hybrid models and already-convergent hits use ``get_computed_blocks``.
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
The ``get_computed_blocks`` triple (blocks, number of local computed
|
| 342 |
+
tokens, shared-prefix boundary) plus ``hit_diverged``.
|
| 343 |
+
"""
|
| 344 |
+
coordinator = self.coordinator
|
| 345 |
+
if not (
|
| 346 |
+
self.kv_cache_config.has_mamba_layers
|
| 347 |
+
and isinstance(coordinator, HybridKVCacheCoordinator)
|
| 348 |
+
and coordinator.full_attention_group_id is not None
|
| 349 |
+
):
|
| 350 |
+
return *self.get_computed_blocks(request), False
|
| 351 |
+
|
| 352 |
+
if not self.prefix_cache_lookup_enabled(request):
|
| 353 |
+
return self.empty_kv_cache_blocks, 0, 0, False
|
| 354 |
+
|
| 355 |
+
fa_group_id = coordinator.full_attention_group_id
|
| 356 |
+
computed, per_group_hits = coordinator.find_longest_cache_hit_per_group(
|
| 357 |
+
request.block_hashes, request.num_tokens - 1
|
| 358 |
+
)
|
| 359 |
+
if any(hit > per_group_hits[fa_group_id] for hit in per_group_hits):
|
| 360 |
+
# A lagging group hit deeper than full attention means its
|
| 361 |
+
# full-attention blocks were evicted; use the reconciled boundary
|
| 362 |
+
# that every group agrees on.
|
| 363 |
+
return *self.get_computed_blocks(request), False
|
| 364 |
+
|
| 365 |
+
num_local = per_group_hits[fa_group_id]
|
| 366 |
+
blocks = self.create_kv_cache_blocks(computed)
|
| 367 |
+
# Per-group lookups do not detect an uncached shared prefix (boundary 0).
|
| 368 |
+
return blocks, num_local, 0, min(per_group_hits) < num_local
|
| 369 |
+
|
| 370 |
+
def allocate_slots(
|
| 371 |
+
self,
|
| 372 |
+
request: Request,
|
| 373 |
+
num_new_tokens: int,
|
| 374 |
+
num_new_computed_tokens: int = 0,
|
| 375 |
+
new_computed_blocks: KVCacheBlocks | None = None,
|
| 376 |
+
num_lookahead_tokens: int = 0,
|
| 377 |
+
num_external_computed_tokens: int = 0,
|
| 378 |
+
delay_cache_blocks: bool = False,
|
| 379 |
+
num_encoder_tokens: int = 0,
|
| 380 |
+
full_sequence_must_fit: bool = False,
|
| 381 |
+
reserved_blocks: int = 0,
|
| 382 |
+
has_scheduled_reqs: bool = True,
|
| 383 |
+
num_latent_positions: int = 0, # FORK: zero-token latent KV positions
|
| 384 |
+
) -> KVCacheBlocks | None:
|
| 385 |
+
"""Add slots for a request with new tokens to append.
|
| 386 |
+
|
| 387 |
+
Args:
|
| 388 |
+
request: The request to allocate slots.
|
| 389 |
+
num_new_tokens: The number of new tokens to be allocated and computed.
|
| 390 |
+
num_new_computed_tokens: The number of new computed tokens just
|
| 391 |
+
hitting the prefix caching, excluding external tokens.
|
| 392 |
+
new_computed_blocks: The cached blocks for the above new computed
|
| 393 |
+
tokens, grouped as a tuple by kv cache groups.
|
| 394 |
+
num_lookahead_tokens: The number of speculative tokens to allocate.
|
| 395 |
+
This is used by spec decode proposers with kv-cache such
|
| 396 |
+
as eagle.
|
| 397 |
+
num_external_computed_tokens: The number of tokens that their
|
| 398 |
+
KV caches are not cached by vLLM but cached by the connector.
|
| 399 |
+
delay_cache_blocks: Whether to skip caching the blocks. This is
|
| 400 |
+
used by P/D when allocating blocks used in a KV transfer
|
| 401 |
+
which will complete in a future step.
|
| 402 |
+
num_encoder_tokens: The number of encoder tokens to allocate for
|
| 403 |
+
cross-attention in encoder-decoder models(e.g., Whisper).
|
| 404 |
+
For decoder-only models, this should be 0.
|
| 405 |
+
full_sequence_must_fit: Only allocate blocks if the KV cache has enough
|
| 406 |
+
free blocks to hold the full sequence, accounting for prefix cache hits
|
| 407 |
+
and sliding window. Used as an admission gate to prevent over-admitting
|
| 408 |
+
requests when chunked prefill would otherwise only check the first chunk
|
| 409 |
+
reserved_blocks: Number of free blocks that must be left available for
|
| 410 |
+
other in-flight sequences to complete. The actual allocation is only
|
| 411 |
+
made if it fits within (free blocks - reserved_blocks). Used to gate
|
| 412 |
+
async KV-connector loads so their initial allocation cannot consume
|
| 413 |
+
blocks an already in-flight (prefilling) sequence is relying on.
|
| 414 |
+
has_scheduled_reqs: Whether any requests are already scheduled to run
|
| 415 |
+
this step, controls whether watermark is applied.
|
| 416 |
+
num_latent_positions: FORK: number of zero-token latent KV positions
|
| 417 |
+
to allocate for this step (HybridAgent latent steps consume a KV
|
| 418 |
+
position without a token id).
|
| 419 |
+
|
| 420 |
+
Blocks layout:
|
| 421 |
+
```
|
| 422 |
+
----------------------------------------------------------------------
|
| 423 |
+
| < comp > | < new_comp > | < ext_comp > | < new > | < lookahead > |
|
| 424 |
+
----------------------------------------------------------------------
|
| 425 |
+
| < to be computed > |
|
| 426 |
+
----------------------------------------------------------------------
|
| 427 |
+
| < to be allocated > |
|
| 428 |
+
----------------------------------------------------------------------
|
| 429 |
+
| < to be cached (roughly, |
|
| 430 |
+
| details below)> |
|
| 431 |
+
----------------------------------------------------------------------
|
| 432 |
+
| Prefix-cached tokens from either vLLM |
|
| 433 |
+
| or connector. Can be safely removed if |
|
| 434 |
+
| they are outside sliding window. |
|
| 435 |
+
----------------------------------------------------------------------
|
| 436 |
+
| < cached by vLLM > | not cached by |
|
| 437 |
+
| vLLM, but |
|
| 438 |
+
| ref_cnt | ref_cnt not | cached by |
|
| 439 |
+
| increased| increased yet| connector |
|
| 440 |
+
----------------------------------------------------------------------
|
| 441 |
+
```
|
| 442 |
+
|
| 443 |
+
Abbrivations:
|
| 444 |
+
|
| 445 |
+
```
|
| 446 |
+
comp = request.num_computed_tokens
|
| 447 |
+
new_comp = num_new_computed_tokens
|
| 448 |
+
= len(new_computed_blocks) * block_size
|
| 449 |
+
ext_comp = num_external_computed_tokens, cached by the connector
|
| 450 |
+
new = num_new_tokens, including unverified draft tokens
|
| 451 |
+
lookahead = num_lookahead_tokens
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
NOTE: for new tokens which include both verified and unverified draft
|
| 455 |
+
tokens, we only cache the verified tokens (by capping the number at
|
| 456 |
+
`request.num_tokens`).
|
| 457 |
+
|
| 458 |
+
The allocation has three stages:
|
| 459 |
+
- Free unnecessary blocks in `comp` and check
|
| 460 |
+
if we have sufficient free blocks (return None if not).
|
| 461 |
+
- Handle prefix tokens (`comp + new_comp + ext_comp`):
|
| 462 |
+
- Free unnecessary blocks (e.g. outside sliding window)
|
| 463 |
+
- Allocate new blocks for `ext_comp` tokens inside
|
| 464 |
+
sliding window
|
| 465 |
+
- Allocate new blocks for tokens to be computed (`new + lookahead`)
|
| 466 |
+
|
| 467 |
+
Returns:
|
| 468 |
+
A list of new allocated blocks.
|
| 469 |
+
"""
|
| 470 |
+
# When loading KV data asynchronously, we may have zero new tokens to
|
| 471 |
+
# compute while still allocating slots for externally computed tokens.
|
| 472 |
+
# FORK: a zero-token latent step (num_latent_positions > 0) also
|
| 473 |
+
# allocates slots without any new token id.
|
| 474 |
+
if (
|
| 475 |
+
num_new_tokens == 0
|
| 476 |
+
and num_external_computed_tokens == 0
|
| 477 |
+
and num_latent_positions == 0
|
| 478 |
+
):
|
| 479 |
+
raise ValueError(
|
| 480 |
+
"num_new_tokens must be greater than 0 when there are no "
|
| 481 |
+
"external computed tokens"
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
if new_computed_blocks is not None:
|
| 485 |
+
new_computed_block_list = new_computed_blocks.blocks
|
| 486 |
+
if self.retained_hit_group_ids:
|
| 487 |
+
# Only retain the prefix covered by the local and external hits.
|
| 488 |
+
reused = num_new_computed_tokens + num_external_computed_tokens
|
| 489 |
+
groups = list(new_computed_block_list)
|
| 490 |
+
for group_id in self.retained_hit_group_ids:
|
| 491 |
+
manager = self.coordinator.single_type_managers[group_id]
|
| 492 |
+
groups[group_id] = groups[group_id][
|
| 493 |
+
: cdiv(reused, manager.block_size)
|
| 494 |
+
]
|
| 495 |
+
new_computed_block_list = tuple(groups)
|
| 496 |
+
else:
|
| 497 |
+
new_computed_block_list = self.empty_kv_cache_blocks.blocks
|
| 498 |
+
|
| 499 |
+
# The number of computed tokens is the number of computed tokens plus
|
| 500 |
+
# the new prefix caching hits
|
| 501 |
+
num_local_computed_tokens = (
|
| 502 |
+
request.num_computed_tokens + num_new_computed_tokens
|
| 503 |
+
)
|
| 504 |
+
total_computed_tokens = min(
|
| 505 |
+
num_local_computed_tokens + num_external_computed_tokens,
|
| 506 |
+
self.max_model_len,
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
watermark_blocks = 0
|
| 510 |
+
# The watermark is applied to waiting/preempted requests only, and only
|
| 511 |
+
# when there's at least one request already scheduled.
|
| 512 |
+
if has_scheduled_reqs and request.status in (
|
| 513 |
+
RequestStatus.WAITING,
|
| 514 |
+
RequestStatus.PREEMPTED,
|
| 515 |
+
):
|
| 516 |
+
watermark_blocks = self.watermark_blocks
|
| 517 |
+
|
| 518 |
+
if full_sequence_must_fit:
|
| 519 |
+
# First check and fail if the full request sequence won't fit.
|
| 520 |
+
full_num_tokens = min(request.num_tokens, self.max_model_len)
|
| 521 |
+
|
| 522 |
+
num_blocks_to_allocate = self.coordinator.get_num_blocks_to_allocate(
|
| 523 |
+
request_id=request.request_id,
|
| 524 |
+
num_tokens=full_num_tokens,
|
| 525 |
+
new_computed_blocks=new_computed_block_list,
|
| 526 |
+
num_encoder_tokens=num_encoder_tokens,
|
| 527 |
+
total_computed_tokens=total_computed_tokens,
|
| 528 |
+
num_local_computed_tokens=num_local_computed_tokens,
|
| 529 |
+
num_tokens_main_model=full_num_tokens,
|
| 530 |
+
apply_admission_cap=True,
|
| 531 |
+
)
|
| 532 |
+
required_blocks = num_blocks_to_allocate + watermark_blocks
|
| 533 |
+
if required_blocks > self.block_pool.get_num_free_blocks():
|
| 534 |
+
return None
|
| 535 |
+
|
| 536 |
+
# FORK: latent positions are KV positions too, so the block table must
|
| 537 |
+
# extend to cover them even though no token id exists for them.
|
| 538 |
+
num_tokens_main_model = (
|
| 539 |
+
total_computed_tokens + num_new_tokens + num_latent_positions
|
| 540 |
+
)
|
| 541 |
+
num_tokens_need_slot = min(
|
| 542 |
+
num_tokens_main_model + num_lookahead_tokens, self.max_model_len
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# Free the blocks that are skipped during the attention computation
|
| 546 |
+
# (e.g., tokens outside the sliding window).
|
| 547 |
+
# We can do this even if we cannot schedule this request due to
|
| 548 |
+
# insufficient free blocks.
|
| 549 |
+
# Should call this function before allocating new blocks to reduce
|
| 550 |
+
# the number of evicted blocks.
|
| 551 |
+
# Free on the processed-token basis: in-flight steps' attention windows
|
| 552 |
+
# still read blocks below the optimistic boundary, and rejected spec
|
| 553 |
+
# tokens can roll it back.
|
| 554 |
+
self.coordinator.remove_skipped_blocks(
|
| 555 |
+
request.request_id,
|
| 556 |
+
max(0, total_computed_tokens - request.num_in_flight_tokens),
|
| 557 |
+
num_prompt_tokens=request.num_prompt_tokens,
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
num_blocks_to_allocate = self.coordinator.get_num_blocks_to_allocate(
|
| 561 |
+
request_id=request.request_id,
|
| 562 |
+
num_tokens=num_tokens_need_slot,
|
| 563 |
+
new_computed_blocks=new_computed_block_list,
|
| 564 |
+
num_encoder_tokens=num_encoder_tokens,
|
| 565 |
+
total_computed_tokens=num_local_computed_tokens
|
| 566 |
+
+ num_external_computed_tokens,
|
| 567 |
+
num_local_computed_tokens=num_local_computed_tokens,
|
| 568 |
+
num_tokens_main_model=num_tokens_main_model,
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
# Keep `reserved_blocks` free for other in-flight sequences, and an
|
| 572 |
+
# additional watermark of headroom for waiting/preempted admissions.
|
| 573 |
+
available_blocks = self.block_pool.get_num_free_blocks() - reserved_blocks
|
| 574 |
+
required_blocks = num_blocks_to_allocate + watermark_blocks
|
| 575 |
+
if required_blocks > available_blocks:
|
| 576 |
+
# Cannot allocate new blocks
|
| 577 |
+
return None
|
| 578 |
+
|
| 579 |
+
if (
|
| 580 |
+
new_computed_block_list is not self.empty_kv_cache_blocks.blocks
|
| 581 |
+
or num_external_computed_tokens > 0
|
| 582 |
+
):
|
| 583 |
+
# Append the new computed blocks to the request blocks until now to
|
| 584 |
+
# avoid the case where the new blocks cannot be allocated.
|
| 585 |
+
self.coordinator.allocate_new_computed_blocks(
|
| 586 |
+
request_id=request.request_id,
|
| 587 |
+
new_computed_blocks=new_computed_block_list,
|
| 588 |
+
num_local_computed_tokens=num_local_computed_tokens,
|
| 589 |
+
num_external_computed_tokens=num_external_computed_tokens,
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
new_blocks = self.coordinator.allocate_new_blocks(
|
| 593 |
+
request.request_id,
|
| 594 |
+
num_tokens_need_slot,
|
| 595 |
+
num_tokens_main_model,
|
| 596 |
+
num_encoder_tokens,
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
# P/D: delay caching blocks if we have to recv from
|
| 600 |
+
# remote. Update state for locally cached blocks.
|
| 601 |
+
if delay_cache_blocks:
|
| 602 |
+
return self.create_kv_cache_blocks(new_blocks)
|
| 603 |
+
|
| 604 |
+
# NOTE(woosuk): We want to commit (cache) up to num_local_computed_tokens
|
| 605 |
+
# + num_external_computed_tokens + num_new_tokens, but must exclude
|
| 606 |
+
# "non-committable" tokens (e.g., draft tokens that could be rejected).
|
| 607 |
+
# Therefore, we cap the number at `request.num_tokens`, ensuring only
|
| 608 |
+
# "finalized" tokens are cached.
|
| 609 |
+
# FORK: never cache beyond the frozen cacheable prefix. A latent
|
| 610 |
+
# block's KV derives from an injected embedding, not a token id, so it
|
| 611 |
+
# must never enter the prefix cache (and BlockPool.cache_full_blocks
|
| 612 |
+
# would index past request.block_hashes if it did).
|
| 613 |
+
num_tokens_to_cache = min(
|
| 614 |
+
total_computed_tokens + num_new_tokens,
|
| 615 |
+
request.num_tokens,
|
| 616 |
+
request.kv_cacheable_prefix or self.max_model_len,
|
| 617 |
+
)
|
| 618 |
+
self.coordinator.cache_blocks(request, num_tokens_to_cache)
|
| 619 |
+
|
| 620 |
+
return self.create_kv_cache_blocks(new_blocks)
|
| 621 |
+
|
| 622 |
+
def free(self, request: Request) -> None:
|
| 623 |
+
"""Free the blocks allocated for the request.
|
| 624 |
+
We free the blocks in reverse order so that the tail blocks are evicted
|
| 625 |
+
first when caching is enabled.
|
| 626 |
+
|
| 627 |
+
Args:
|
| 628 |
+
request: The request to free the blocks.
|
| 629 |
+
"""
|
| 630 |
+
self.coordinator.free(request.request_id)
|
| 631 |
+
|
| 632 |
+
def remove_skipped_blocks(
|
| 633 |
+
self,
|
| 634 |
+
request_id: str,
|
| 635 |
+
processed_computed_tokens: int,
|
| 636 |
+
num_prompt_tokens: int | None = None,
|
| 637 |
+
) -> None:
|
| 638 |
+
"""Remove the blocks that are no longer needed from `blocks` and replace
|
| 639 |
+
the removed blocks with null_block.
|
| 640 |
+
|
| 641 |
+
Args:
|
| 642 |
+
request_id: The request ID.
|
| 643 |
+
processed_computed_tokens: Computed-token prefix length covering
|
| 644 |
+
fully processed and committed tokens only (safe to free).
|
| 645 |
+
num_prompt_tokens: Optional prompt length for R-SWA gap eviction.
|
| 646 |
+
"""
|
| 647 |
+
self.coordinator.remove_skipped_blocks(
|
| 648 |
+
request_id, processed_computed_tokens, num_prompt_tokens
|
| 649 |
+
)
|
| 650 |
+
|
| 651 |
+
def pop_blocks_for_free(self, request: Request) -> list[KVCacheBlock]:
|
| 652 |
+
"""Pop the request's bookkeeping and return its blocks without
|
| 653 |
+
returning them to the block pool. The caller must eventually free
|
| 654 |
+
them in reverse order (so that tail blocks are evicted first).
|
| 655 |
+
|
| 656 |
+
Args:
|
| 657 |
+
request: The request to pop the blocks for.
|
| 658 |
+
|
| 659 |
+
Returns:
|
| 660 |
+
The request's blocks in allocation order.
|
| 661 |
+
"""
|
| 662 |
+
return self.coordinator.pop_blocks_for_free(request.request_id)
|
| 663 |
+
|
| 664 |
+
def evict_blocks(self, block_ids: set[int]) -> None:
|
| 665 |
+
"""evict blocks from the prefix cache by their block IDs.
|
| 666 |
+
|
| 667 |
+
Args:
|
| 668 |
+
block_ids: Set of block IDs to evict from cache.
|
| 669 |
+
"""
|
| 670 |
+
self.block_pool.evict_blocks(block_ids)
|
| 671 |
+
|
| 672 |
+
def reset_prefix_cache(self) -> bool:
|
| 673 |
+
"""Reset prefix cache. This function may be used in RLHF
|
| 674 |
+
flows to invalidate prefix caching after the weights are updated,
|
| 675 |
+
or used for resetting prefix caching status for benchmarking.
|
| 676 |
+
|
| 677 |
+
Returns:
|
| 678 |
+
bool: True if the prefix cache is successfully reset,
|
| 679 |
+
False otherwise.
|
| 680 |
+
"""
|
| 681 |
+
if not self.coordinator.reset_prefix_cache():
|
| 682 |
+
return False
|
| 683 |
+
if self.log_stats:
|
| 684 |
+
assert self.prefix_cache_stats is not None
|
| 685 |
+
self.prefix_cache_stats.reset = True
|
| 686 |
+
return True
|
| 687 |
+
|
| 688 |
+
def get_num_common_prefix_blocks(self, running_request_id: str) -> list[int]:
|
| 689 |
+
"""Calculate the number of common prefix blocks for each kv cache group.
|
| 690 |
+
|
| 691 |
+
The function selects a running request and iterates through its blocks.
|
| 692 |
+
A block is considered a common prefix block if ALL requests with
|
| 693 |
+
allocated KV cache share it (i.e., ref_cnt equals the number of entries
|
| 694 |
+
in req_to_blocks).
|
| 695 |
+
|
| 696 |
+
NOTE(woosuk): The number of requests with allocated KV cache is **greater
|
| 697 |
+
than or equal to** the number of requests scheduled in the current step.
|
| 698 |
+
This is because having allocated KV cache only indicates that:
|
| 699 |
+
1. The request has not yet finished, and
|
| 700 |
+
2. The request holds its blocks unfreed.
|
| 701 |
+
|
| 702 |
+
While all scheduled requests must have allocated KV cache, the inverse
|
| 703 |
+
is not necessarily true. There may be requests with allocated KV cache
|
| 704 |
+
that are not scheduled in the current step.
|
| 705 |
+
|
| 706 |
+
This can result in an edge case where the number of common prefix blocks
|
| 707 |
+
is 0, even though all scheduled requests share a common prefix. This
|
| 708 |
+
occurs because there may be unscheduled requests that do not share the
|
| 709 |
+
common prefix. Currently, this case cannot be easily detected, so the
|
| 710 |
+
function returns 0 in such cases.
|
| 711 |
+
|
| 712 |
+
Args:
|
| 713 |
+
running_request_id: The request ID of any running request, used to
|
| 714 |
+
identify the common prefix blocks.
|
| 715 |
+
|
| 716 |
+
Returns:
|
| 717 |
+
list[int]: The number of common prefix blocks for each kv cache
|
| 718 |
+
group.
|
| 719 |
+
"""
|
| 720 |
+
return self.coordinator.get_num_common_prefix_blocks(running_request_id)
|
| 721 |
+
|
| 722 |
+
def take_events(self) -> list[KVCacheEvent]:
|
| 723 |
+
"""Take the KV cache events from the block pool.
|
| 724 |
+
|
| 725 |
+
Returns:
|
| 726 |
+
A list of KV cache events.
|
| 727 |
+
"""
|
| 728 |
+
events = self.block_pool.take_events()
|
| 729 |
+
for event in events:
|
| 730 |
+
if not isinstance(event, BlockStored):
|
| 731 |
+
continue
|
| 732 |
+
if event.group_idx is None:
|
| 733 |
+
continue
|
| 734 |
+
if event.group_idx < 0 or event.group_idx >= len(
|
| 735 |
+
self.kv_cache_event_metadata
|
| 736 |
+
):
|
| 737 |
+
logger.warning(
|
| 738 |
+
"Group index `%s` not in KV cache metadata", event.group_idx
|
| 739 |
+
)
|
| 740 |
+
continue
|
| 741 |
+
# Annotate here so BlockPool can keep emitting structural cache
|
| 742 |
+
# events without owning semantic KV cache spec metadata.
|
| 743 |
+
kind, sliding_window = self.kv_cache_event_metadata[event.group_idx]
|
| 744 |
+
event.kv_cache_spec_kind = kind
|
| 745 |
+
event.kv_cache_spec_sliding_window = sliding_window
|
| 746 |
+
return events
|
| 747 |
+
|
| 748 |
+
def get_blocks(self, request_id: str) -> KVCacheBlocks:
|
| 749 |
+
"""Get the blocks of a request."""
|
| 750 |
+
return self.create_kv_cache_blocks(self.coordinator.get_blocks(request_id))
|
| 751 |
+
|
| 752 |
+
def get_block_ids(self, request_id: str) -> tuple[list[int], ...]:
|
| 753 |
+
"""Get the block ids of a request."""
|
| 754 |
+
return self.get_blocks(request_id).get_block_ids()
|
| 755 |
+
|
| 756 |
+
def get_block_ids_for_computed_tokens(
|
| 757 |
+
self,
|
| 758 |
+
request_id: str,
|
| 759 |
+
num_computed_tokens: int,
|
| 760 |
+
) -> tuple[list[int], ...]:
|
| 761 |
+
"""Get block ids covering the request's computed tokens."""
|
| 762 |
+
block_ids = self.get_block_ids(request_id)
|
| 763 |
+
clipped_block_ids: list[list[int]] = []
|
| 764 |
+
for group, ids in zip(self.kv_cache_config.kv_cache_groups, block_ids):
|
| 765 |
+
spec = group.kv_cache_spec
|
| 766 |
+
if not isinstance(spec, AttentionSpec) or isinstance(
|
| 767 |
+
spec, (CrossAttentionSpec, EncoderOnlyAttentionSpec)
|
| 768 |
+
):
|
| 769 |
+
clipped_block_ids.append(ids)
|
| 770 |
+
continue
|
| 771 |
+
|
| 772 |
+
num_valid_blocks = cdiv(num_computed_tokens, spec.block_size)
|
| 773 |
+
clipped_block_ids.append(ids[:num_valid_blocks])
|
| 774 |
+
return tuple(clipped_block_ids)
|
| 775 |
+
|
| 776 |
+
def estimate_cached_tokens(self, request: Request) -> int:
|
| 777 |
+
"""Estimate the number of tokens cached by the request."""
|
| 778 |
+
cached_tokens: int | None = None
|
| 779 |
+
for group, blocks in zip(
|
| 780 |
+
self.kv_cache_config.kv_cache_groups,
|
| 781 |
+
self.get_blocks(request.request_id).blocks,
|
| 782 |
+
):
|
| 783 |
+
if not group.kv_cache_spec.prefix_cacheable:
|
| 784 |
+
continue
|
| 785 |
+
if isinstance(
|
| 786 |
+
group.kv_cache_spec,
|
| 787 |
+
(CrossAttentionSpec, EncoderOnlyAttentionSpec),
|
| 788 |
+
):
|
| 789 |
+
# Cross-attention and encoder-only groups are not prefix cached.
|
| 790 |
+
continue
|
| 791 |
+
|
| 792 |
+
group_cached_tokens = 0
|
| 793 |
+
for block in blocks:
|
| 794 |
+
group_cached_tokens = max(
|
| 795 |
+
group_cached_tokens,
|
| 796 |
+
block.block_hash_num_tokens or 0,
|
| 797 |
+
)
|
| 798 |
+
|
| 799 |
+
cached_tokens = (
|
| 800 |
+
group_cached_tokens
|
| 801 |
+
if cached_tokens is None
|
| 802 |
+
else min(cached_tokens, group_cached_tokens)
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
return cached_tokens or 0
|
| 806 |
+
|
| 807 |
+
def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
|
| 808 |
+
"""Cache the blocks for the request, if enabled.
|
| 809 |
+
|
| 810 |
+
Args:
|
| 811 |
+
request: The request to cache the blocks.
|
| 812 |
+
num_computed_tokens: The number of computed tokens, including tokens
|
| 813 |
+
that are already cached and tokens to be cached.
|
| 814 |
+
"""
|
| 815 |
+
if self.enable_caching:
|
| 816 |
+
self.coordinator.cache_blocks(request, num_computed_tokens)
|
| 817 |
+
|
| 818 |
+
def create_kv_cache_blocks(
|
| 819 |
+
self, blocks: tuple[list[KVCacheBlock], ...]
|
| 820 |
+
) -> KVCacheBlocks:
|
| 821 |
+
# Only create new KVCacheBlocks for non-empty blocks
|
| 822 |
+
return KVCacheBlocks(blocks) if any(blocks) else self.empty_kv_cache_blocks
|
| 823 |
+
|
| 824 |
+
def truncate_computed_blocks(
|
| 825 |
+
self, blocks: KVCacheBlocks, num_computed_tokens: int
|
| 826 |
+
) -> KVCacheBlocks:
|
| 827 |
+
"""Return a lookup-result view truncated at an aligned token endpoint.
|
| 828 |
+
|
| 829 |
+
An external hit can supply the final Mamba state even when the local
|
| 830 |
+
Mamba group ends before this endpoint. Other groups must cover it.
|
| 831 |
+
Pure slicing: refcounts are untouched and ``blocks`` is not mutated.
|
| 832 |
+
"""
|
| 833 |
+
truncated: list[list[KVCacheBlock]] = []
|
| 834 |
+
for group_blocks, manager, group in zip(
|
| 835 |
+
blocks.blocks,
|
| 836 |
+
self.coordinator.single_type_managers,
|
| 837 |
+
self.kv_cache_config.kv_cache_groups,
|
| 838 |
+
strict=True,
|
| 839 |
+
):
|
| 840 |
+
if not group.kv_cache_spec.prefix_cacheable:
|
| 841 |
+
# Scratch groups (e.g. the CSA compressor ring) hold a fixed
|
| 842 |
+
# block covering no token range, so a lookup result never
|
| 843 |
+
# carries computed blocks for them and their block size does
|
| 844 |
+
# not divide the endpoint.
|
| 845 |
+
assert not group_blocks
|
| 846 |
+
truncated.append([])
|
| 847 |
+
continue
|
| 848 |
+
assert num_computed_tokens % manager.block_size == 0
|
| 849 |
+
num_blocks = num_computed_tokens // manager.block_size
|
| 850 |
+
if isinstance(group.kv_cache_spec, MambaSpec):
|
| 851 |
+
num_blocks = min(num_blocks, len(group_blocks))
|
| 852 |
+
else:
|
| 853 |
+
assert num_blocks <= len(group_blocks)
|
| 854 |
+
truncated.append(list(group_blocks[:num_blocks]))
|
| 855 |
+
return self.create_kv_cache_blocks(tuple(truncated))
|
| 856 |
+
|
| 857 |
+
def take_new_block_ids(self) -> list[int]:
|
| 858 |
+
"""Drain and return new attention block IDs for zeroing."""
|
| 859 |
+
ids: list[int] = []
|
| 860 |
+
for mgr in self.coordinator.single_type_managers:
|
| 861 |
+
ids.extend(mgr.take_new_block_ids())
|
| 862 |
+
return ids
|
| 863 |
+
|
| 864 |
+
def get_zeroing_block_ids_in_range(
|
| 865 |
+
self, request_id: str, start_token: int, end_token: int
|
| 866 |
+
) -> list[int]:
|
| 867 |
+
"""The request's block ids covering [start_token, end_token), from
|
| 868 |
+
the groups whose new blocks are zeroed by the worker."""
|
| 869 |
+
ids: list[int] = []
|
| 870 |
+
for mgr in self.coordinator.single_type_managers:
|
| 871 |
+
if mgr.records_new_block_ids:
|
| 872 |
+
start_idx = start_token // mgr.block_size
|
| 873 |
+
end_idx = cdiv(end_token, mgr.block_size)
|
| 874 |
+
blocks = mgr.req_to_blocks[request_id]
|
| 875 |
+
ids.extend(blk.block_id for blk in blocks[start_idx:end_idx])
|
| 876 |
+
return ids
|
| 877 |
+
|
| 878 |
+
def record_blocks_for_zeroing(self, request_id: str, start_token: int) -> None:
|
| 879 |
+
"""Re-record the request's blocks from start_token onwards for
|
| 880 |
+
zeroing, e.g. blocks a failed async KV load left unwritten.
|
| 881 |
+
|
| 882 |
+
start_token must be block-aligned: zeroing a partially-valid block
|
| 883 |
+
would wipe its valid prefix.
|
| 884 |
+
"""
|
| 885 |
+
for mgr in self.coordinator.single_type_managers:
|
| 886 |
+
if mgr.records_new_block_ids:
|
| 887 |
+
assert start_token % mgr.block_size == 0
|
| 888 |
+
start_idx = start_token // mgr.block_size
|
| 889 |
+
blocks = mgr.req_to_blocks[request_id]
|
| 890 |
+
mgr.new_block_ids.extend(blk.block_id for blk in blocks[start_idx:])
|
| 891 |
+
|
| 892 |
+
def take_kv_cache_block_copies(
|
| 893 |
+
self,
|
| 894 |
+
) -> tuple[list[KVCacheBlockCopy], list[KVCacheBlock]]:
|
| 895 |
+
"""Drain pending copies and return their retained endpoints."""
|
| 896 |
+
pending_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
|
| 897 |
+
for mgr in self.coordinator.single_type_managers:
|
| 898 |
+
pending_copies.extend(mgr.take_pending_cow_copies())
|
| 899 |
+
copies = [
|
| 900 |
+
KVCacheBlockCopy(
|
| 901 |
+
src_block_id=source_block.block_id,
|
| 902 |
+
dst_block_id=cow_block.block_id,
|
| 903 |
+
)
|
| 904 |
+
for source_block, cow_block in pending_copies
|
| 905 |
+
]
|
| 906 |
+
retained_blocks = [block for pair in pending_copies for block in pair]
|
| 907 |
+
return copies, retained_blocks
|
| 908 |
+
|
| 909 |
+
def take_boundary_state_offloads(
|
| 910 |
+
self,
|
| 911 |
+
) -> dict[str, list[tuple[int, int, int]]]:
|
| 912 |
+
"""Drain this step's boundary-state hand-offs for a KV connector.
|
| 913 |
+
|
| 914 |
+
Returns ``{request_id: [(group_id, block_id, boundary_tokens), ...]}``
|
| 915 |
+
for mamba "align" boundary states: the
|
| 916 |
+
request's committed boundary-state snapshots and, on a sub-block partial
|
| 917 |
+
hit, the CoW copy of its last-prompt-boundary state. Only mamba "align"
|
| 918 |
+
groups contribute; empty otherwise. A connector reads the referenced
|
| 919 |
+
blocks — never resolving them positionally — and offloads them so a
|
| 920 |
+
later request can hit that prefix.
|
| 921 |
+
"""
|
| 922 |
+
offloads: dict[str, list[tuple[int, int, int]]] = {}
|
| 923 |
+
for mgr in self.coordinator.single_type_managers:
|
| 924 |
+
for (
|
| 925 |
+
req_id,
|
| 926 |
+
group_id,
|
| 927 |
+
block,
|
| 928 |
+
boundary_tokens,
|
| 929 |
+
) in mgr.take_pending_boundary_state_offloads():
|
| 930 |
+
offloads.setdefault(req_id, []).append(
|
| 931 |
+
(group_id, block.block_id, boundary_tokens)
|
| 932 |
+
)
|
| 933 |
+
return offloads
|
| 934 |
+
|
| 935 |
+
def finalize_partial_tail_offloads(
|
| 936 |
+
self, request: Request
|
| 937 |
+
) -> list[tuple[int, int, int]]:
|
| 938 |
+
"""Consume safe producer partial tails when a request finishes.
|
| 939 |
+
|
| 940 |
+
A mamba align table block is a valid boundary source only if no later
|
| 941 |
+
token was forwarded. The connector pins and queues the exact table
|
| 942 |
+
block before request cleanup, then releases the pin when every worker
|
| 943 |
+
reports the store job complete.
|
| 944 |
+
"""
|
| 945 |
+
offloads: list[tuple[int, int, int]] = []
|
| 946 |
+
for mgr in self.coordinator.single_type_managers:
|
| 947 |
+
finalized = mgr.finalize_partial_tail_offload(
|
| 948 |
+
request.request_id,
|
| 949 |
+
request.num_computed_tokens,
|
| 950 |
+
request.num_in_flight_tokens,
|
| 951 |
+
)
|
| 952 |
+
if finalized is None:
|
| 953 |
+
continue
|
| 954 |
+
group_id, block, boundary_tokens = finalized
|
| 955 |
+
offloads.append((group_id, block.block_id, boundary_tokens))
|
| 956 |
+
return offloads
|
| 957 |
+
|
| 958 |
+
def new_step_starts(self) -> None:
|
| 959 |
+
"""Notify the coordinator that a new step is starting."""
|
| 960 |
+
self.coordinator.new_step_starts()
|
serving/fork/v1/core/sched/output.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
| 3 |
+
|
| 4 |
+
from collections.abc import Callable
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from functools import cached_property
|
| 7 |
+
from typing import TYPE_CHECKING
|
| 8 |
+
|
| 9 |
+
from vllm.config.ec_manager_config import EncoderCacheManagerMetadata
|
| 10 |
+
from vllm.multimodal.utils import strip_covered_mm_data
|
| 11 |
+
|
| 12 |
+
if TYPE_CHECKING:
|
| 13 |
+
import numpy as np
|
| 14 |
+
import numpy.typing as npt
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
|
| 18 |
+
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
|
| 19 |
+
from vllm.lora.request import LoRARequest
|
| 20 |
+
from vllm.multimodal.inputs import MultiModalFeatureSpec
|
| 21 |
+
from vllm.pooling_params import PoolingParams
|
| 22 |
+
from vllm.sampling_params import SamplingParams
|
| 23 |
+
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
|
| 24 |
+
from vllm.v1.request import Request
|
| 25 |
+
else:
|
| 26 |
+
ECConnectorMetadata = object
|
| 27 |
+
KVConnectorMetadata = object
|
| 28 |
+
KVCacheBlockCopy = object
|
| 29 |
+
LoRARequest = object
|
| 30 |
+
MultiModalFeatureSpec = object
|
| 31 |
+
PoolingParams = object
|
| 32 |
+
SamplingParams = object
|
| 33 |
+
Request = object
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class NewRequestData:
|
| 38 |
+
req_id: str
|
| 39 |
+
prompt_token_ids: list[int] | None
|
| 40 |
+
mm_features: list[MultiModalFeatureSpec]
|
| 41 |
+
sampling_params: SamplingParams | None
|
| 42 |
+
pooling_params: PoolingParams | None
|
| 43 |
+
block_ids: tuple[list[int], ...]
|
| 44 |
+
num_computed_tokens: int
|
| 45 |
+
lora_request: LoRARequest | None
|
| 46 |
+
prompt_embeds: "torch.Tensor | None" = None
|
| 47 |
+
prompt_is_token_ids: list[bool] | None = None
|
| 48 |
+
|
| 49 |
+
# Only used for v2 model runner.
|
| 50 |
+
prefill_token_ids: list[int] | None = None
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_request(
|
| 54 |
+
cls,
|
| 55 |
+
request: Request,
|
| 56 |
+
block_ids: tuple[list[int], ...],
|
| 57 |
+
prefill_token_ids: list[int] | None = None,
|
| 58 |
+
uses_mrope: bool = False,
|
| 59 |
+
) -> "NewRequestData":
|
| 60 |
+
return cls(
|
| 61 |
+
req_id=request.request_id,
|
| 62 |
+
prompt_token_ids=request.prompt_token_ids,
|
| 63 |
+
mm_features=strip_covered_mm_data(
|
| 64 |
+
request.mm_features,
|
| 65 |
+
request.num_computed_tokens,
|
| 66 |
+
uses_mrope=uses_mrope,
|
| 67 |
+
),
|
| 68 |
+
sampling_params=request.sampling_params,
|
| 69 |
+
pooling_params=request.pooling_params,
|
| 70 |
+
block_ids=block_ids,
|
| 71 |
+
num_computed_tokens=request.num_computed_tokens,
|
| 72 |
+
lora_request=request.lora_request,
|
| 73 |
+
prompt_embeds=request.prompt_embeds,
|
| 74 |
+
prompt_is_token_ids=request.prompt_is_token_ids,
|
| 75 |
+
prefill_token_ids=prefill_token_ids,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def prompt_len(self) -> int:
|
| 80 |
+
if self.prompt_token_ids is not None:
|
| 81 |
+
return len(self.prompt_token_ids)
|
| 82 |
+
if self.prompt_embeds is not None:
|
| 83 |
+
return self.prompt_embeds.shape[0]
|
| 84 |
+
return 0
|
| 85 |
+
|
| 86 |
+
def __repr__(self) -> str:
|
| 87 |
+
prompt_embeds_shape = (
|
| 88 |
+
self.prompt_embeds.shape if self.prompt_embeds is not None else None
|
| 89 |
+
)
|
| 90 |
+
return (
|
| 91 |
+
f"NewRequestData("
|
| 92 |
+
f"req_id={self.req_id},"
|
| 93 |
+
f"prompt_token_ids={self.prompt_token_ids},"
|
| 94 |
+
f"prefill_token_ids={self.prefill_token_ids},"
|
| 95 |
+
f"mm_features={self.mm_features},"
|
| 96 |
+
f"sampling_params={self.sampling_params},"
|
| 97 |
+
f"block_ids={self.block_ids},"
|
| 98 |
+
f"num_computed_tokens={self.num_computed_tokens},"
|
| 99 |
+
f"lora_request={self.lora_request},"
|
| 100 |
+
f"prompt_embeds_shape={prompt_embeds_shape}"
|
| 101 |
+
")"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Version of __repr__ with the prompt data obfuscated
|
| 105 |
+
def anon_repr(self) -> str:
|
| 106 |
+
prompt_token_ids_len = (
|
| 107 |
+
len(self.prompt_token_ids) if self.prompt_token_ids is not None else None
|
| 108 |
+
)
|
| 109 |
+
prompt_embeds_shape = (
|
| 110 |
+
self.prompt_embeds.shape if self.prompt_embeds is not None else None
|
| 111 |
+
)
|
| 112 |
+
prefill_token_ids_len = (
|
| 113 |
+
len(self.prefill_token_ids) if self.prefill_token_ids is not None else None
|
| 114 |
+
)
|
| 115 |
+
return (
|
| 116 |
+
f"NewRequestData("
|
| 117 |
+
f"req_id={self.req_id},"
|
| 118 |
+
f"prompt_token_ids_len={prompt_token_ids_len},"
|
| 119 |
+
f"prefill_token_ids_len={prefill_token_ids_len},"
|
| 120 |
+
f"mm_features={self.mm_features},"
|
| 121 |
+
f"sampling_params={self.sampling_params},"
|
| 122 |
+
f"block_ids={self.block_ids},"
|
| 123 |
+
f"num_computed_tokens={self.num_computed_tokens},"
|
| 124 |
+
f"lora_request={self.lora_request},"
|
| 125 |
+
f"prompt_embeds_shape={prompt_embeds_shape}"
|
| 126 |
+
")"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@dataclass
|
| 131 |
+
class CachedRequestData:
|
| 132 |
+
req_ids: list[str]
|
| 133 |
+
# For request ids not in resumed_req_ids, new_block_ids will be appended to
|
| 134 |
+
# the request's block IDs. For those in the set, new_block_ids will be used as the
|
| 135 |
+
# request's block IDs instead of appending to the existing block IDs.
|
| 136 |
+
resumed_req_ids: set[str]
|
| 137 |
+
# NOTE(woosuk): new_token_ids is only used for pipeline parallelism.
|
| 138 |
+
# When PP is not used, new_token_ids will be empty.
|
| 139 |
+
new_token_ids: list[list[int]]
|
| 140 |
+
# MRV1-only: For requests not scheduled in the last step, propagate the token ids
|
| 141 |
+
# to the connector. Won't contain requests scheduled in the prior step.
|
| 142 |
+
all_token_ids: dict[str, list[int]]
|
| 143 |
+
new_block_ids: list[tuple[list[int], ...] | None]
|
| 144 |
+
num_computed_tokens: list[int]
|
| 145 |
+
num_output_tokens: list[int]
|
| 146 |
+
|
| 147 |
+
# Version of dataclass repr with token IDs obfuscated.
|
| 148 |
+
def anon_repr(self) -> str:
|
| 149 |
+
new_token_ids_lens = [len(toks) for toks in self.new_token_ids]
|
| 150 |
+
all_token_ids_lens = {
|
| 151 |
+
req_id: len(toks) for req_id, toks in self.all_token_ids.items()
|
| 152 |
+
}
|
| 153 |
+
return (
|
| 154 |
+
f"CachedRequestData("
|
| 155 |
+
f"req_ids={self.req_ids},"
|
| 156 |
+
f"resumed_req_ids={self.resumed_req_ids},"
|
| 157 |
+
f"new_token_ids_lens={new_token_ids_lens},"
|
| 158 |
+
f"all_token_ids_lens={all_token_ids_lens},"
|
| 159 |
+
f"new_block_ids={self.new_block_ids},"
|
| 160 |
+
f"num_computed_tokens={self.num_computed_tokens},"
|
| 161 |
+
f"num_output_tokens={self.num_output_tokens}"
|
| 162 |
+
f")"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
def __repr__(self) -> str:
|
| 166 |
+
return self.anon_repr()
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def num_reqs(self) -> int:
|
| 170 |
+
return len(self.req_ids)
|
| 171 |
+
|
| 172 |
+
@cached_property
|
| 173 |
+
def _req_id_to_num_output_tokens(self) -> dict[str, int]:
|
| 174 |
+
"""Cache mapping of req_id to num_output_tokens for O(1) lookup.
|
| 175 |
+
|
| 176 |
+
This cached property is safe because CachedRequestData instances
|
| 177 |
+
are created fresh each scheduling iteration and not mutated during
|
| 178 |
+
computation of iteration details.
|
| 179 |
+
"""
|
| 180 |
+
return dict(zip(self.req_ids, self.num_output_tokens))
|
| 181 |
+
|
| 182 |
+
def is_context_phase(self, req_id: str) -> bool:
|
| 183 |
+
num_output_tokens = self._req_id_to_num_output_tokens.get(req_id)
|
| 184 |
+
return num_output_tokens is not None and num_output_tokens == 0
|
| 185 |
+
|
| 186 |
+
@classmethod
|
| 187 |
+
def make_empty(cls) -> "CachedRequestData":
|
| 188 |
+
return cls(
|
| 189 |
+
req_ids=[],
|
| 190 |
+
resumed_req_ids=set(),
|
| 191 |
+
new_token_ids=[],
|
| 192 |
+
all_token_ids={},
|
| 193 |
+
new_block_ids=[],
|
| 194 |
+
num_computed_tokens=[],
|
| 195 |
+
num_output_tokens=[],
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@dataclass
|
| 200 |
+
class ScheduledEncoderInputStats:
|
| 201 |
+
"""Stats for encoder inputs scheduled in one iteration."""
|
| 202 |
+
|
| 203 |
+
num_inputs: int = 0
|
| 204 |
+
output_tokens: int = 0
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@dataclass
|
| 208 |
+
class KVConnectorBlockState:
|
| 209 |
+
"""Scheduler-local block state offered to a producer-side KV connector."""
|
| 210 |
+
|
| 211 |
+
# Requests scheduled this step and requests with a boundary-state hand-off.
|
| 212 |
+
req_ids: set[str]
|
| 213 |
+
# Resolve on access to avoid copying tables the connector never reads.
|
| 214 |
+
resolve_block_ids: Callable[[str], tuple[list[int], ...]]
|
| 215 |
+
# Exact Mamba "align" boundary-state hand-offs.
|
| 216 |
+
boundary_state_offloads: dict[str, list[tuple[int, int, int]]]
|
| 217 |
+
|
| 218 |
+
def get_block_ids(self, req_id: str) -> tuple[list[int], ...] | None:
|
| 219 |
+
if req_id not in self.req_ids:
|
| 220 |
+
return None
|
| 221 |
+
return self.resolve_block_ids(req_id)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@dataclass
|
| 225 |
+
class SchedulerOutput:
|
| 226 |
+
# list of the requests that are scheduled for the first time.
|
| 227 |
+
# We cache the request's data in each worker process, so that we don't
|
| 228 |
+
# need to re-send it every scheduling step.
|
| 229 |
+
scheduled_new_reqs: list[NewRequestData]
|
| 230 |
+
# list of the requests that have been scheduled before.
|
| 231 |
+
# Since the request's data is already cached in the worker processes,
|
| 232 |
+
# we only send the diff to minimize the communication cost.
|
| 233 |
+
scheduled_cached_reqs: CachedRequestData
|
| 234 |
+
|
| 235 |
+
# req_id -> num_scheduled_tokens
|
| 236 |
+
# Number of tokens scheduled for each request.
|
| 237 |
+
num_scheduled_tokens: dict[str, int]
|
| 238 |
+
# Total number of tokens scheduled for all requests.
|
| 239 |
+
# Equal to sum(num_scheduled_tokens.values())
|
| 240 |
+
total_num_scheduled_tokens: int
|
| 241 |
+
# req_id -> spec_token_ids
|
| 242 |
+
# If a request does not have any spec decode tokens, it will not be
|
| 243 |
+
# included in the dictionary.
|
| 244 |
+
scheduled_spec_decode_tokens: dict[str, list[int]]
|
| 245 |
+
# req_id -> encoder input indices that need processing.
|
| 246 |
+
# E.g., if a request has [0, 1], it could mean the vision encoder needs
|
| 247 |
+
# to process that the request's 0-th and 1-th images in the current step.
|
| 248 |
+
scheduled_encoder_inputs: dict[str, list[int]]
|
| 249 |
+
# Number of common prefix blocks for all requests in each KV cache group.
|
| 250 |
+
# This can be used for cascade attention.
|
| 251 |
+
num_common_prefix_blocks: list[int]
|
| 252 |
+
|
| 253 |
+
# Request IDs that are finished in between the previous and the current
|
| 254 |
+
# steps. This is used to notify the workers about the finished requests
|
| 255 |
+
# so that they can free the cached states for those requests.
|
| 256 |
+
finished_req_ids: set[str]
|
| 257 |
+
# list of mm_hash strings associated with the encoder outputs to be
|
| 258 |
+
# freed from the encoder cache.
|
| 259 |
+
free_encoder_mm_hashes: list[str]
|
| 260 |
+
# FORK: req_id -> number of zero-token latent KV positions scheduled this
|
| 261 |
+
# step (HybridAgent latent steps consume a KV position without a token id).
|
| 262 |
+
# Empty when no latent step is scheduled.
|
| 263 |
+
latent_steps: dict[str, int] = field(default_factory=dict)
|
| 264 |
+
# FORK: req_id -> 1 for the latent ENTRY step: a pending-latent request
|
| 265 |
+
# that still has a pending token id (the `<latent>` token position). The
|
| 266 |
+
# model runner drops that step's sample so the position becomes a latent
|
| 267 |
+
# KV position fed by project_latent(prev_hidden).
|
| 268 |
+
latent_entry_steps: dict[str, int] = field(default_factory=dict)
|
| 269 |
+
|
| 270 |
+
scheduled_encoder_input_stats: ScheduledEncoderInputStats | None = None
|
| 271 |
+
|
| 272 |
+
# Request IDs that are preempted in this step.
|
| 273 |
+
# Only used for v2 model runner.
|
| 274 |
+
preempted_req_ids: set[str] | None = None
|
| 275 |
+
|
| 276 |
+
# Whether any of the scheduled requests use structured output.
|
| 277 |
+
# Set only in async scheduling case.
|
| 278 |
+
has_structured_output_requests: bool = False
|
| 279 |
+
|
| 280 |
+
# Whether the scheduled requests have all the output tokens they
|
| 281 |
+
# need to perform grammar bitmask computation.
|
| 282 |
+
pending_structured_output_tokens: bool = False
|
| 283 |
+
|
| 284 |
+
# Used for adjusting acceptance rate calculation.
|
| 285 |
+
num_invalid_spec_tokens: dict[str, int] | None = None
|
| 286 |
+
|
| 287 |
+
# KV Cache Connector metadata.
|
| 288 |
+
kv_connector_metadata: KVConnectorMetadata | None = None
|
| 289 |
+
|
| 290 |
+
# Whether any scheduled request consumes KV that the connector loads
|
| 291 |
+
# synchronously during this step (load_async=False).
|
| 292 |
+
has_sync_kv_loads: bool = False
|
| 293 |
+
|
| 294 |
+
# EC Cache Connector metadata
|
| 295 |
+
ec_connector_metadata: ECConnectorMetadata | None = None
|
| 296 |
+
# EC Cache Manager metadata
|
| 297 |
+
ec_manager_metadata: EncoderCacheManagerMetadata | None = None
|
| 298 |
+
# Block IDs freshly allocated from the pool during this scheduling step.
|
| 299 |
+
# The worker zeros the corresponding GPU memory before the blocks are used,
|
| 300 |
+
# preventing stale NaN/data from corrupting attention or SSM computation.
|
| 301 |
+
new_block_ids_to_zero: list[int] | None = None
|
| 302 |
+
|
| 303 |
+
# CoW copies to apply after zeroing new blocks and before forward.
|
| 304 |
+
kv_cache_block_copies: list[KVCacheBlockCopy] | None = None
|
| 305 |
+
|
| 306 |
+
# Complete block-table rows that replace incrementally appended block IDs.
|
| 307 |
+
block_table_updates: dict[str, tuple[list[int], ...]] | None = None
|
| 308 |
+
|
| 309 |
+
# Scheduler-local; always None by the time this reaches a worker.
|
| 310 |
+
kv_connector_block_state: KVConnectorBlockState | None = None
|
| 311 |
+
|
| 312 |
+
# Dynamic speculative decoding: optimal K chosen by scheduler.
|
| 313 |
+
# Number of spec tokens to schedule for the next step.
|
| 314 |
+
num_spec_tokens_to_schedule: int = 0
|
| 315 |
+
|
| 316 |
+
@classmethod
|
| 317 |
+
def make_empty(cls) -> "SchedulerOutput":
|
| 318 |
+
return cls(
|
| 319 |
+
scheduled_new_reqs=[],
|
| 320 |
+
scheduled_cached_reqs=CachedRequestData.make_empty(),
|
| 321 |
+
num_scheduled_tokens={},
|
| 322 |
+
total_num_scheduled_tokens=0,
|
| 323 |
+
scheduled_spec_decode_tokens={},
|
| 324 |
+
scheduled_encoder_inputs={},
|
| 325 |
+
num_common_prefix_blocks=[],
|
| 326 |
+
finished_req_ids=set(),
|
| 327 |
+
free_encoder_mm_hashes=[],
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@dataclass
|
| 332 |
+
class GrammarOutput:
|
| 333 |
+
# ids of structured output requests.
|
| 334 |
+
structured_output_request_ids: list[str]
|
| 335 |
+
# Bitmask ordered as structured_output_request_ids.
|
| 336 |
+
grammar_bitmask: "npt.NDArray[np.int32]"
|
serving/fork/v1/core/sched/scheduler.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
serving/fork/v1/request.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
| 3 |
+
|
| 4 |
+
import enum
|
| 5 |
+
import time
|
| 6 |
+
from collections import deque
|
| 7 |
+
from collections.abc import Callable, Mapping
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import TYPE_CHECKING, Any
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
from vllm.multimodal.inputs import MultiModalFeatureSpec
|
| 14 |
+
from vllm.pooling_params import PoolingParams
|
| 15 |
+
from vllm.sampling_params import SamplingParams
|
| 16 |
+
from vllm.utils import length_from_prompt_token_ids_or_embeds
|
| 17 |
+
from vllm.v1.engine import (
|
| 18 |
+
EngineCoreEvent,
|
| 19 |
+
EngineCoreEventType,
|
| 20 |
+
EngineCoreRequest,
|
| 21 |
+
FinishReason,
|
| 22 |
+
)
|
| 23 |
+
from vllm.v1.metrics.stats import PrefillStats, RequestSpecDecodeMetrics
|
| 24 |
+
from vllm.v1.structured_output.request import StructuredOutputRequest
|
| 25 |
+
from vllm.v1.utils import ConstantList
|
| 26 |
+
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
from vllm.lora.request import LoRARequest
|
| 29 |
+
from vllm.v1.core.kv_cache_utils import BlockHash
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class StreamingUpdate:
|
| 34 |
+
"""Lightweight data for streaming session continuation.
|
| 35 |
+
|
| 36 |
+
Contains only the fields needed to update an existing streaming session
|
| 37 |
+
with new input data.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
mm_features: list[MultiModalFeatureSpec] | None
|
| 41 |
+
prompt_token_ids: list[int] | None
|
| 42 |
+
max_tokens: int
|
| 43 |
+
arrival_time: float
|
| 44 |
+
sampling_params: SamplingParams | None
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def from_request(cls, request: "Request") -> "StreamingUpdate | None":
|
| 48 |
+
if not request.resumable:
|
| 49 |
+
return None
|
| 50 |
+
return cls(
|
| 51 |
+
mm_features=request.mm_features,
|
| 52 |
+
prompt_token_ids=request.prompt_token_ids,
|
| 53 |
+
max_tokens=request.max_tokens,
|
| 54 |
+
arrival_time=request.arrival_time,
|
| 55 |
+
sampling_params=request.sampling_params,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class Request:
|
| 60 |
+
def __init__(
|
| 61 |
+
self,
|
| 62 |
+
request_id: str,
|
| 63 |
+
prompt_token_ids: list[int] | None,
|
| 64 |
+
sampling_params: SamplingParams | None,
|
| 65 |
+
pooling_params: PoolingParams | None,
|
| 66 |
+
client_index: int = 0,
|
| 67 |
+
arrival_time: float | None = None,
|
| 68 |
+
prompt_embeds: torch.Tensor | None = None,
|
| 69 |
+
prompt_is_token_ids: list[bool] | None = None,
|
| 70 |
+
mm_features: list[MultiModalFeatureSpec] | None = None,
|
| 71 |
+
lora_request: "LoRARequest | None" = None,
|
| 72 |
+
cache_salt: str | None = None,
|
| 73 |
+
priority: int = 0,
|
| 74 |
+
trace_headers: Mapping[str, str] | None = None,
|
| 75 |
+
block_hasher: Callable[["Request"], list["BlockHash"]] | None = None,
|
| 76 |
+
resumable: bool = False,
|
| 77 |
+
session_id: str | None = None,
|
| 78 |
+
reasoning_ended: bool | None = None,
|
| 79 |
+
reasoning_parser_kwargs: dict[str, Any] | None = None,
|
| 80 |
+
abort_immediately: bool = False,
|
| 81 |
+
) -> None:
|
| 82 |
+
self.request_id = request_id
|
| 83 |
+
self.client_index = client_index
|
| 84 |
+
self.priority = priority
|
| 85 |
+
self.sampling_params = sampling_params
|
| 86 |
+
self.pooling_params = pooling_params
|
| 87 |
+
self.lora_request = lora_request
|
| 88 |
+
self.structured_output_request = StructuredOutputRequest.from_sampling_params(
|
| 89 |
+
sampling_params
|
| 90 |
+
)
|
| 91 |
+
if self.structured_output_request is not None:
|
| 92 |
+
self.structured_output_request.reasoning_ended = reasoning_ended
|
| 93 |
+
self.structured_output_request.reasoning_parser_kwargs = (
|
| 94 |
+
reasoning_parser_kwargs
|
| 95 |
+
)
|
| 96 |
+
self.arrival_time = arrival_time if arrival_time is not None else time.time()
|
| 97 |
+
|
| 98 |
+
self.status = RequestStatus.WAITING
|
| 99 |
+
self.events: list[EngineCoreEvent] = []
|
| 100 |
+
self.stop_reason: int | str | None = None
|
| 101 |
+
|
| 102 |
+
# P/D: Connector-specific KV transfer parameters.
|
| 103 |
+
self.kv_transfer_params: dict[str, Any] | None = None
|
| 104 |
+
# E/P/D: Connector-specific encoder-cache transfer parameters.
|
| 105 |
+
self.ec_transfer_params: dict[str, Any] | None = None
|
| 106 |
+
|
| 107 |
+
if pooling_params is not None:
|
| 108 |
+
# Pooling models.
|
| 109 |
+
self.max_tokens = 1
|
| 110 |
+
elif sampling_params is not None:
|
| 111 |
+
# Generative models.
|
| 112 |
+
assert sampling_params.max_tokens is not None
|
| 113 |
+
self.max_tokens = sampling_params.max_tokens
|
| 114 |
+
if self.structured_output_request is not None:
|
| 115 |
+
self.status = RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR
|
| 116 |
+
|
| 117 |
+
if sampling_params.extra_args is not None:
|
| 118 |
+
self.kv_transfer_params = sampling_params.extra_args.get(
|
| 119 |
+
"kv_transfer_params"
|
| 120 |
+
)
|
| 121 |
+
self.ec_transfer_params = sampling_params.extra_args.get(
|
| 122 |
+
"ec_transfer_params"
|
| 123 |
+
)
|
| 124 |
+
self.kv_cache_report_mode = sampling_params.extra_args.get(
|
| 125 |
+
"kv_cache_report_mode", "incremental"
|
| 126 |
+
)
|
| 127 |
+
else:
|
| 128 |
+
self.kv_cache_report_mode = "incremental"
|
| 129 |
+
else:
|
| 130 |
+
raise ValueError("sampling_params and pooling_params can't both be unset")
|
| 131 |
+
|
| 132 |
+
self.prompt_token_ids = prompt_token_ids
|
| 133 |
+
self.prompt_embeds = prompt_embeds
|
| 134 |
+
# Per-position mask used in mixed-mode (chat completion with
|
| 135 |
+
# prompt_embeds). `None` except when both `prompt_token_ids` and
|
| 136 |
+
# `prompt_embeds` are set and their positions are interleaved.
|
| 137 |
+
self.prompt_is_token_ids = prompt_is_token_ids
|
| 138 |
+
# Cache per-block prompt-embed hashes to avoid rehashing the same
|
| 139 |
+
# tensor slices when generating extra keys.
|
| 140 |
+
self._prompt_embeds_per_block_hashes: dict[tuple[int, int], bytes] = {}
|
| 141 |
+
self.num_prompt_tokens = length_from_prompt_token_ids_or_embeds(
|
| 142 |
+
prompt_token_ids, prompt_embeds
|
| 143 |
+
)
|
| 144 |
+
self._output_token_ids: list[int] = []
|
| 145 |
+
if self.prompt_token_ids is None:
|
| 146 |
+
self._all_token_ids: list[int] = [0] * self.num_prompt_tokens
|
| 147 |
+
elif self.prompt_is_token_ids is None:
|
| 148 |
+
self._all_token_ids = self.prompt_token_ids.copy()
|
| 149 |
+
else:
|
| 150 |
+
# Mixed-mode prompt: positions covered by prompt_embeds hold a sentinel
|
| 151 |
+
# special token id that may lie outside the embedding. Zero them, matching
|
| 152 |
+
# the no-token-ids case above, so embedding gathers over these placeholder
|
| 153 |
+
# ids stay in bounds; the actual inputs come from prompt_embeds.
|
| 154 |
+
self._all_token_ids = [
|
| 155 |
+
t if is_tok else 0
|
| 156 |
+
for t, is_tok in zip(self.prompt_token_ids, self.prompt_is_token_ids)
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
# Used in async scheduling.
|
| 160 |
+
self.num_output_placeholders = 0
|
| 161 |
+
# Tokens of output in flight when the request was preempted: delivered
|
| 162 |
+
# on return, but must not mutate the reset counters.
|
| 163 |
+
self.num_stale_output_tokens = 0
|
| 164 |
+
# Drop the stale output instead, for same-step preempt + resume
|
| 165 |
+
# (reset_prefix_cache).
|
| 166 |
+
self.drop_stale_output = False
|
| 167 |
+
|
| 168 |
+
# Tokens of steps whose output is not yet processed (async scheduling
|
| 169 |
+
# and PP run ahead of the GPU); `num_computed_tokens` counts them
|
| 170 |
+
# optimistically.
|
| 171 |
+
self.num_in_flight_tokens = 0
|
| 172 |
+
|
| 173 |
+
# V2+PP+async: Enforces `pp_size` cadence between same-request decode steps
|
| 174 |
+
# so the worker's broadcast slot ring stays consistent.
|
| 175 |
+
self.next_decode_eligible_step = 0
|
| 176 |
+
|
| 177 |
+
# Seq of the most recent step this request was scheduled in; fences
|
| 178 |
+
# deferred block freeing (see Scheduler._free_request_blocks).
|
| 179 |
+
self.last_sched_seq = 0
|
| 180 |
+
|
| 181 |
+
self.spec_token_ids: list[int] = []
|
| 182 |
+
self.num_computed_tokens = 0
|
| 183 |
+
# FORK: zero-token latent steps (HybridAgent). Latent KV positions
|
| 184 |
+
# filled so far; invariant:
|
| 185 |
+
# num_computed_tokens == real token KV positions + num_latent_computed.
|
| 186 |
+
self.num_latent_computed = 0
|
| 187 |
+
# FORK: frozen to num_computed_tokens at the first latent step; latent
|
| 188 |
+
# blocks are allocated but never prefix-cached (their KV derives from
|
| 189 |
+
# an injected embedding, not a token id).
|
| 190 |
+
self.kv_cacheable_prefix: int | None = None
|
| 191 |
+
# FORK: set by the state machine (T4/T5) to mark the next step as a
|
| 192 |
+
# zero-token latent step.
|
| 193 |
+
self.pending_latent = False
|
| 194 |
+
self.cache_salt: str | None = cache_salt
|
| 195 |
+
|
| 196 |
+
# Multi-modal related
|
| 197 |
+
self.mm_features = mm_features or []
|
| 198 |
+
|
| 199 |
+
# Read-only views
|
| 200 |
+
# Prevent directly appending to these lists since
|
| 201 |
+
# they should also be updated simultaneously.
|
| 202 |
+
self.output_token_ids = ConstantList(self._output_token_ids)
|
| 203 |
+
self.all_token_ids = ConstantList(self._all_token_ids)
|
| 204 |
+
# trace_headers
|
| 205 |
+
self.trace_headers = trace_headers
|
| 206 |
+
self.session_id = session_id
|
| 207 |
+
|
| 208 |
+
# True if this request is scheduled as a non-final prefill chunk.
|
| 209 |
+
self.is_prefill_chunk = False
|
| 210 |
+
|
| 211 |
+
# Block-aligned token position of a proven shared prefix worth pinning
|
| 212 |
+
# in the (sparse) prefix cache; 0 means none. Set at admission for
|
| 213 |
+
# hybrid/Mamba models when a shared prefix is detected (Marconi-style).
|
| 214 |
+
self.shared_prefix_boundary = 0
|
| 215 |
+
|
| 216 |
+
# The number of NaNs in logits. A value greater than 0
|
| 217 |
+
# indicates that the output is corrupted
|
| 218 |
+
self.num_nans_in_logits = 0
|
| 219 |
+
|
| 220 |
+
# The number of times this request has been preempted by the scheduler.
|
| 221 |
+
self.num_preemptions = 0
|
| 222 |
+
|
| 223 |
+
self.prefill_stats: PrefillStats | None = PrefillStats()
|
| 224 |
+
|
| 225 |
+
# Per-request speculative-decoding acceptance accumulator. Populated by
|
| 226 |
+
# the scheduler when --per-request-spec-decode-metrics is set (eagerly on
|
| 227 |
+
# add_request, then observed each verify step); stays None otherwise.
|
| 228 |
+
self.spec_decode_metrics: RequestSpecDecodeMetrics | None = None
|
| 229 |
+
|
| 230 |
+
self.block_hashes: list[BlockHash] = []
|
| 231 |
+
# Store the block hasher without binding self to avoid creating a
|
| 232 |
+
# reference cycle (Request -> partial -> Request) that prevents
|
| 233 |
+
# immediate garbage collection via reference counting.
|
| 234 |
+
self._block_hasher: Callable[[Request], list[BlockHash]] | None = block_hasher
|
| 235 |
+
self.update_block_hashes()
|
| 236 |
+
|
| 237 |
+
self.skip_reading_prefix_cache = self.get_skip_reading_prefix_cache()
|
| 238 |
+
|
| 239 |
+
# Used for streaming
|
| 240 |
+
self.resumable = resumable
|
| 241 |
+
# None entry in the queue means finished.
|
| 242 |
+
self.streaming_queue: deque[StreamingUpdate | None] | None = None
|
| 243 |
+
|
| 244 |
+
# If True, request should be aborted immediately after being added to
|
| 245 |
+
# the scheduler so the connector's request_finished hook runs.
|
| 246 |
+
self.abort_immediately = abort_immediately
|
| 247 |
+
|
| 248 |
+
@classmethod
|
| 249 |
+
def from_engine_core_request(
|
| 250 |
+
cls,
|
| 251 |
+
request: EngineCoreRequest,
|
| 252 |
+
block_hasher: Callable[["Request"], list["BlockHash"]] | None,
|
| 253 |
+
) -> "Request":
|
| 254 |
+
return cls(
|
| 255 |
+
request_id=request.request_id,
|
| 256 |
+
client_index=request.client_index,
|
| 257 |
+
prompt_token_ids=request.prompt_token_ids,
|
| 258 |
+
prompt_embeds=request.prompt_embeds,
|
| 259 |
+
prompt_is_token_ids=request.prompt_is_token_ids,
|
| 260 |
+
mm_features=request.mm_features,
|
| 261 |
+
sampling_params=request.sampling_params,
|
| 262 |
+
pooling_params=request.pooling_params,
|
| 263 |
+
arrival_time=request.arrival_time,
|
| 264 |
+
lora_request=request.lora_request,
|
| 265 |
+
cache_salt=request.cache_salt,
|
| 266 |
+
priority=request.priority,
|
| 267 |
+
trace_headers=request.trace_headers,
|
| 268 |
+
block_hasher=block_hasher,
|
| 269 |
+
resumable=request.resumable,
|
| 270 |
+
session_id=request.session_id,
|
| 271 |
+
reasoning_ended=request.reasoning_ended,
|
| 272 |
+
reasoning_parser_kwargs=request.reasoning_parser_kwargs,
|
| 273 |
+
abort_immediately=request.abort_immediately,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def append_output_token_ids(
|
| 277 |
+
self,
|
| 278 |
+
token_ids: int | list[int],
|
| 279 |
+
) -> None:
|
| 280 |
+
if isinstance(token_ids, int):
|
| 281 |
+
self._output_token_ids.append(token_ids)
|
| 282 |
+
self._all_token_ids.append(token_ids)
|
| 283 |
+
else:
|
| 284 |
+
self._output_token_ids.extend(token_ids)
|
| 285 |
+
self._all_token_ids.extend(token_ids)
|
| 286 |
+
|
| 287 |
+
self.update_block_hashes()
|
| 288 |
+
|
| 289 |
+
def update_block_hashes(self) -> None:
|
| 290 |
+
"""Compute block hashes for any new full blocks and append them."""
|
| 291 |
+
if self._block_hasher is not None:
|
| 292 |
+
self.block_hashes.extend(self._block_hasher(self))
|
| 293 |
+
|
| 294 |
+
@property
|
| 295 |
+
def use_structured_output(self) -> bool:
|
| 296 |
+
return self.structured_output_request is not None
|
| 297 |
+
|
| 298 |
+
@property
|
| 299 |
+
def num_tokens(self) -> int:
|
| 300 |
+
return len(self._all_token_ids)
|
| 301 |
+
|
| 302 |
+
@property
|
| 303 |
+
def num_tokens_with_spec(self) -> int:
|
| 304 |
+
return len(self._all_token_ids) + len(self.spec_token_ids)
|
| 305 |
+
|
| 306 |
+
@property
|
| 307 |
+
def num_output_tokens(self) -> int:
|
| 308 |
+
return len(self._output_token_ids)
|
| 309 |
+
|
| 310 |
+
@property
|
| 311 |
+
def num_encoder_inputs(self) -> int:
|
| 312 |
+
return len(self.mm_features)
|
| 313 |
+
|
| 314 |
+
@property
|
| 315 |
+
def has_encoder_inputs(self) -> bool:
|
| 316 |
+
return self.num_encoder_inputs > 0
|
| 317 |
+
|
| 318 |
+
def get_skip_reading_prefix_cache(self) -> bool:
|
| 319 |
+
if (
|
| 320 |
+
self.sampling_params is not None
|
| 321 |
+
and self.sampling_params.skip_reading_prefix_cache is not None
|
| 322 |
+
):
|
| 323 |
+
return self.sampling_params.skip_reading_prefix_cache
|
| 324 |
+
elif (
|
| 325 |
+
self.pooling_params is not None
|
| 326 |
+
and self.pooling_params.skip_reading_prefix_cache is not None
|
| 327 |
+
):
|
| 328 |
+
return self.pooling_params.skip_reading_prefix_cache
|
| 329 |
+
return False
|
| 330 |
+
|
| 331 |
+
def is_finished(self) -> bool:
|
| 332 |
+
return RequestStatus.is_finished(self.status)
|
| 333 |
+
|
| 334 |
+
def get_finished_reason(self) -> FinishReason | None:
|
| 335 |
+
return RequestStatus.get_finished_reason(self.status)
|
| 336 |
+
|
| 337 |
+
def get_num_encoder_embeds(self, input_id: int) -> int:
|
| 338 |
+
assert input_id < len(self.mm_features)
|
| 339 |
+
return self.mm_features[input_id].mm_position.get_num_embeds()
|
| 340 |
+
|
| 341 |
+
def record_event(
|
| 342 |
+
self,
|
| 343 |
+
event_type: EngineCoreEventType,
|
| 344 |
+
timestamp: float | None = None,
|
| 345 |
+
) -> None:
|
| 346 |
+
self.events.append(EngineCoreEvent.new_event(event_type, timestamp))
|
| 347 |
+
|
| 348 |
+
def take_events(self) -> list[EngineCoreEvent] | None:
|
| 349 |
+
if not self.events:
|
| 350 |
+
return None
|
| 351 |
+
events, self.events = self.events, []
|
| 352 |
+
return events
|
| 353 |
+
|
| 354 |
+
def take_prefill_stats(self) -> PrefillStats | None:
|
| 355 |
+
if self.prefill_stats is None:
|
| 356 |
+
return None
|
| 357 |
+
prefill_stats = self.prefill_stats
|
| 358 |
+
self.prefill_stats = None
|
| 359 |
+
return prefill_stats
|
| 360 |
+
|
| 361 |
+
def __lt__(self, other: "Request") -> bool:
|
| 362 |
+
"""
|
| 363 |
+
Compare two requests based on priority, arrival time, and request ID.
|
| 364 |
+
Used in priority scheduling.
|
| 365 |
+
"""
|
| 366 |
+
if self.priority != other.priority:
|
| 367 |
+
return self.priority < other.priority
|
| 368 |
+
if self.arrival_time != other.arrival_time:
|
| 369 |
+
return self.arrival_time < other.arrival_time
|
| 370 |
+
if self.request_id != other.request_id:
|
| 371 |
+
return self.request_id < other.request_id
|
| 372 |
+
return id(self) < id(other)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
class RequestStatus(enum.IntEnum):
|
| 376 |
+
"""Status of a request."""
|
| 377 |
+
|
| 378 |
+
WAITING = enum.auto()
|
| 379 |
+
WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR = enum.auto()
|
| 380 |
+
WAITING_FOR_REMOTE_KVS = enum.auto()
|
| 381 |
+
WAITING_FOR_STREAMING_REQ = enum.auto()
|
| 382 |
+
RUNNING = enum.auto()
|
| 383 |
+
PREEMPTED = enum.auto()
|
| 384 |
+
# Note: anything after PREEMPTED will be considered
|
| 385 |
+
# as a finished status.
|
| 386 |
+
FINISHED_STOPPED = enum.auto()
|
| 387 |
+
FINISHED_LENGTH_CAPPED = enum.auto()
|
| 388 |
+
FINISHED_ABORTED = enum.auto()
|
| 389 |
+
FINISHED_IGNORED = enum.auto()
|
| 390 |
+
FINISHED_ERROR = enum.auto()
|
| 391 |
+
FINISHED_REPETITION = enum.auto()
|
| 392 |
+
|
| 393 |
+
def __str__(self) -> str:
|
| 394 |
+
return self.name
|
| 395 |
+
|
| 396 |
+
@staticmethod
|
| 397 |
+
def is_finished(status: "RequestStatus") -> bool:
|
| 398 |
+
return status > RequestStatus.PREEMPTED
|
| 399 |
+
|
| 400 |
+
@staticmethod
|
| 401 |
+
def get_finished_reason(status: "RequestStatus") -> FinishReason | None:
|
| 402 |
+
return _FINISHED_REASON_MAP.get(status)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# Mapping of finished statuses to their finish reasons.
|
| 406 |
+
# NOTE: The ignored requests are the requests whose prompt lengths
|
| 407 |
+
# are longer than the model's length cap. Therefore, the stop
|
| 408 |
+
# reason should also be "length" as in OpenAI API.
|
| 409 |
+
_FINISHED_REASON_MAP = {
|
| 410 |
+
RequestStatus.FINISHED_STOPPED: FinishReason.STOP,
|
| 411 |
+
RequestStatus.FINISHED_LENGTH_CAPPED: FinishReason.LENGTH,
|
| 412 |
+
RequestStatus.FINISHED_ABORTED: FinishReason.ABORT,
|
| 413 |
+
RequestStatus.FINISHED_IGNORED: FinishReason.LENGTH,
|
| 414 |
+
RequestStatus.FINISHED_ERROR: FinishReason.ERROR,
|
| 415 |
+
RequestStatus.WAITING_FOR_STREAMING_REQ: FinishReason.STOP,
|
| 416 |
+
RequestStatus.FINISHED_REPETITION: FinishReason.REPETITION,
|
| 417 |
+
}
|
serving/fork/v1/worker/gpu_model_runner.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
serving/orchestrator/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# hybridagent-orchestrator (T5)
|
| 2 |
+
|
| 3 |
+
The EXTERNAL ORCHESTRATOR for the forked vLLM deployment: it wraps an
|
| 4 |
+
IN-PROCESS `vllm.LLMEngine` and runs the HybridAgent agentic loop that
|
| 5 |
+
the T4 state machine (`processor/`) cannot finish on its own.
|
| 6 |
+
|
| 7 |
+
## What it does
|
| 8 |
+
|
| 9 |
+
- **Engine construction** with the fork's required semantics:
|
| 10 |
+
`VLLM_USE_V2_MODEL_RUNNER=0` (the T2 fork modifies the V1 runner; the
|
| 11 |
+
V2 default runner silently disables the latent path) and
|
| 12 |
+
`async_scheduling=False` (the async scheduler keeps `num_new_tokens > 0`
|
| 13 |
+
every step, which blocks the fork's zero-token latent gate).
|
| 14 |
+
- **Arms the T4 state machine** via `processor.register_engine(...)` with
|
| 15 |
+
`get_request` / `get_req_ids` closures bound to the in-process
|
| 16 |
+
`scheduler.requests` and `model_runner.input_batch.req_ids`, the
|
| 17 |
+
tokenizer, a `tool_executor` and the in-process `inject_token`.
|
| 18 |
+
- **`add_request(...)`** with the `<latent>`-capable sampling params
|
| 19 |
+
(greedy, no penalties/structured output, a `max_tokens` budget).
|
| 20 |
+
- **`run(...)`** step loop: `engine.step()`; after each step, for every
|
| 21 |
+
request paused in `IDLE`, read `pending_tool` (tag, content), call
|
| 22 |
+
`tool_executor(tag, content)`, tokenize `<result>{...}</result>`
|
| 23 |
+
(`add_special_tokens=False`) and `queue_observation(rid, tokens)`;
|
| 24 |
+
collect the final text on DONE.
|
| 25 |
+
- **Entry-step alignment**: pre-arms `request.pending_latent` as soon as
|
| 26 |
+
`<latent>` lands in the output, so the step that PROCESSES the anchor
|
| 27 |
+
is the fork's entry row (its sample is dropped).
|
| 28 |
+
- **Exit-anchor injection** (`inject_token`): appends `</latent>` as a
|
| 29 |
+
real sequence token by direct request mutation —
|
| 30 |
+
`request.append_output_token_ids(tok)` + the worker feed buffer
|
| 31 |
+
(`input_batch.token_ids_cpu[req_idx, num_tokens_no_spec[req_idx]]` +
|
| 32 |
+
`is_token_ids`) + re-arming `pending_latent` so the fork drops the
|
| 33 |
+
exit row's own sample (no double-append); the run loop clears the flag
|
| 34 |
+
before the next schedule.
|
| 35 |
+
|
| 36 |
+
## Tools (`orchestrator.tools`)
|
| 37 |
+
|
| 38 |
+
- `python`: bounded, isolated exec (`python -I`, wall-clock timeout,
|
| 39 |
+
capped output) capturing stdout/stderr; returns a string
|
| 40 |
+
(`ERROR: ...` on failure). A resource boundary, not a security sandbox.
|
| 41 |
+
- `search`: Tavily API when `TAVILY_API_KEY` is set; a clear
|
| 42 |
+
not-configured stub otherwise (no network without the key).
|
| 43 |
+
|
| 44 |
+
Both follow the reference contract: `(tag, content) -> result text`.
|
| 45 |
+
|
| 46 |
+
## T7 serve requirements
|
| 47 |
+
|
| 48 |
+
- env: `VLLM_USE_V2_MODEL_RUNNER=0`, `async_scheduling=False`
|
| 49 |
+
- entry points to install: `vllm.general_plugins` (the T3 backend,
|
| 50 |
+
`pip install -e /opt/hybridagent-vllm/backend`) and
|
| 51 |
+
`vllm.logits_processors` (the T4 state machine,
|
| 52 |
+
`pip install -e /opt/hybridagent-vllm/processor`)
|
| 53 |
+
- this package (no entry points of its own) is imported by the serving
|
| 54 |
+
process to drive the engine.
|
| 55 |
+
|
| 56 |
+
Validated by `poc/test_orchestrator_synthetic.py` (tiny HybridAgent model
|
| 57 |
+
through the real fork + backend + state machine: latent cycle, one
|
| 58 |
+
`</python>` tool call, `<result>` re-feed, DONE with coherent text).
|
serving/orchestrator/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HybridAgent external orchestrator (T5).
|
| 2 |
+
|
| 3 |
+
Drives a forked in-process vLLM ``LLMEngine`` step-by-step and runs the
|
| 4 |
+
HybridAgent tool barrier on top of the T4 state machine
|
| 5 |
+
(``HybridAgentLogitsProcessor``):
|
| 6 |
+
|
| 7 |
+
orch = HybridAgentOrchestrator(
|
| 8 |
+
model, # HybridAgent checkpoint dir
|
| 9 |
+
tool_executor=..., # (tag, content) -> result text
|
| 10 |
+
max_turns=30,
|
| 11 |
+
**llm_kwargs, # dtype, enforce_eager, logits_processors, ...
|
| 12 |
+
)
|
| 13 |
+
rid = orch.add_request(prompt) # <latent>-capable sampling params
|
| 14 |
+
results = orch.run() # engine.step() cadence + tool barrier
|
| 15 |
+
|
| 16 |
+
Engine semantics are fixed for the fork: ``VLLM_USE_V2_MODEL_RUNNER=0``
|
| 17 |
+
and ``async_scheduling=False`` (see ``orchestrator.orchestrator``).
|
| 18 |
+
|
| 19 |
+
Install into the serving venv (no entry points; imported as a package):
|
| 20 |
+
|
| 21 |
+
pip install -e /opt/hybridagent-vllm/orchestrator
|
| 22 |
+
"""
|
| 23 |
+
from orchestrator.orchestrator import (
|
| 24 |
+
BarrierEvent,
|
| 25 |
+
HybridAgentOrchestrator,
|
| 26 |
+
)
|
| 27 |
+
from orchestrator.tools import (
|
| 28 |
+
ToolExecutor,
|
| 29 |
+
make_default_tool_executor,
|
| 30 |
+
run_python,
|
| 31 |
+
run_search,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
__all__ = [
|
| 35 |
+
"HybridAgentOrchestrator",
|
| 36 |
+
"BarrierEvent",
|
| 37 |
+
"ToolExecutor",
|
| 38 |
+
"make_default_tool_executor",
|
| 39 |
+
"run_python",
|
| 40 |
+
"run_search",
|
| 41 |
+
]
|
serving/orchestrator/orchestrator.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""External orchestrator for the forked in-process vLLM engine (T5).
|
| 2 |
+
|
| 3 |
+
Drives the HybridAgent agentic loop around a real in-process vLLM
|
| 4 |
+
``LLMEngine``:
|
| 5 |
+
|
| 6 |
+
* builds the engine with the fork's required semantics --
|
| 7 |
+
``VLLM_USE_V2_MODEL_RUNNER=0`` (the T2 fork modifies the V1 runner;
|
| 8 |
+
vLLM 0.30 defaults to V2, where the latent path is silently inactive)
|
| 9 |
+
and ``async_scheduling=False`` (the async scheduler reserves output
|
| 10 |
+
placeholders every step, which keeps ``num_new_tokens > 0`` and
|
| 11 |
+
prevents the fork's zero-token latent gate; L8) -- with an in-process
|
| 12 |
+
engine core so the T4 state machine's registered handles reach
|
| 13 |
+
``scheduler.requests`` and ``model_runner.input_batch.req_ids``;
|
| 14 |
+
* arms the T4 ``HybridAgentLogitsProcessor`` via ``register_engine``
|
| 15 |
+
with the engine-side request lookup, the worker batch order, the
|
| 16 |
+
tokenizer, a ``tool_executor`` and the in-process ``inject_token``
|
| 17 |
+
sequence append;
|
| 18 |
+
* runs the step loop: ``engine.step()`` -> service every request paused
|
| 19 |
+
in IDLE at the tool barrier (run the tool, tokenize
|
| 20 |
+
``<result>...</result>`` with ``add_special_tokens=False``,
|
| 21 |
+
``queue_observation``) -> repeat until every request is done.
|
| 22 |
+
|
| 23 |
+
The two loop behaviors the state machine cannot do from inside the logits
|
| 24 |
+
processor are done here:
|
| 25 |
+
|
| 26 |
+
1. **Entry-step alignment.** The state machine arms
|
| 27 |
+
``request.pending_latent`` one step AFTER it sees ``<latent>``
|
| 28 |
+
sampled; the fork's entry row is the step that PROCESSES ``<latent>``
|
| 29 |
+
(its sample is dropped so the position becomes a latent KV position).
|
| 30 |
+
The orchestrator pre-arms ``pending_latent`` as soon as the entry
|
| 31 |
+
anchor lands in the output -- the same alignment the T2b e2e harness
|
| 32 |
+
used.
|
| 33 |
+
|
| 34 |
+
2. **Exit-anchor injection.** ``inject_token`` appends ``</latent>`` as
|
| 35 |
+
a real sequence token at a latent exit (direct request mutation, see
|
| 36 |
+
the method) and re-arms ``pending_latent`` so the fork's exit-row
|
| 37 |
+
check drops that row's own sample (no double-append of the anchor).
|
| 38 |
+
The run loop clears ``pending_latent`` before the next schedule, so
|
| 39 |
+
the following row is a plain token row that feeds ``</latent>``.
|
| 40 |
+
|
| 41 |
+
The orchestrator also finishes a request at the state machine's DONE
|
| 42 |
+
(e.g. max_turns, fabricated ``<result>``) by aborting it in the engine,
|
| 43 |
+
mirroring the reference engine's stop at DONE.
|
| 44 |
+
"""
|
| 45 |
+
from __future__ import annotations
|
| 46 |
+
|
| 47 |
+
import os
|
| 48 |
+
import uuid
|
| 49 |
+
from dataclasses import dataclass
|
| 50 |
+
|
| 51 |
+
# Fork semantics (L6/L8): force the V1 (forked) runner and the in-process
|
| 52 |
+
# engine core. setdefault: an explicit environment wins.
|
| 53 |
+
os.environ.setdefault("VLLM_USE_V2_MODEL_RUNNER", "0")
|
| 54 |
+
os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
|
| 55 |
+
|
| 56 |
+
from .tools import ToolExecutor, make_default_tool_executor # noqa: E402
|
| 57 |
+
|
| 58 |
+
__all__ = [
|
| 59 |
+
"HybridAgentOrchestrator",
|
| 60 |
+
"ToolExecutor",
|
| 61 |
+
"make_default_tool_executor",
|
| 62 |
+
"BarrierEvent",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class BarrierEvent:
|
| 68 |
+
"""One tool-barrier service: a request paused in IDLE ran a tool."""
|
| 69 |
+
|
| 70 |
+
request_id: str
|
| 71 |
+
tag: str
|
| 72 |
+
content: str
|
| 73 |
+
result: str
|
| 74 |
+
step: int
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class HybridAgentOrchestrator:
|
| 78 |
+
"""Wraps an in-process vLLM ``LLMEngine`` and runs the tool barrier.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
model: checkpoint path (a HybridAgent dir: Qwen3 weights +
|
| 82 |
+
``adapter.pt``; the T3 backend must be registered in this
|
| 83 |
+
process, e.g. ``backend.register()`` or the installed
|
| 84 |
+
``vllm.general_plugins`` entry point).
|
| 85 |
+
tool_executor: ``(tag, content) -> result text``; defaults to
|
| 86 |
+
:func:`make_default_tool_executor` (bounded python +
|
| 87 |
+
Tavily/stub search).
|
| 88 |
+
max_turns: the reference turn cap (default 30).
|
| 89 |
+
llm_kwargs: passed through to ``vllm.LLM`` (``dtype``,
|
| 90 |
+
``enforce_eager``, ``max_model_len``,
|
| 91 |
+
``gpu_memory_utilization``, ``logits_processors``, ...).
|
| 92 |
+
``async_scheduling`` is forced to ``False``.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
model: str,
|
| 98 |
+
*,
|
| 99 |
+
tool_executor: ToolExecutor | None = None,
|
| 100 |
+
max_turns: int = 30,
|
| 101 |
+
**llm_kwargs,
|
| 102 |
+
) -> None:
|
| 103 |
+
from vllm import LLM
|
| 104 |
+
|
| 105 |
+
llm_kwargs.pop("async_scheduling", None)
|
| 106 |
+
llm_kwargs.setdefault("disable_log_stats", True)
|
| 107 |
+
self.llm = LLM(model=model, async_scheduling=False, **llm_kwargs)
|
| 108 |
+
self.engine = self.llm.llm_engine
|
| 109 |
+
# In-process engine core (VLLM_ENABLE_V1_MULTIPROCESSING=0):
|
| 110 |
+
# engine_core is the InprocClient; its .engine_core is the
|
| 111 |
+
# EngineCore holding the scheduler and the model executor.
|
| 112 |
+
self.core = getattr(
|
| 113 |
+
self.engine.engine_core, "engine_core", self.engine.engine_core
|
| 114 |
+
)
|
| 115 |
+
self.scheduler = self.core.scheduler
|
| 116 |
+
self.runner = self.core.model_executor.driver_worker.model_runner
|
| 117 |
+
self.tokenizer = self.engine.get_tokenizer()
|
| 118 |
+
self.max_turns = int(max_turns)
|
| 119 |
+
self.tool_executor = (
|
| 120 |
+
tool_executor if tool_executor is not None
|
| 121 |
+
else make_default_tool_executor()
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Fork handle: the exit-row sample keep/drop reads
|
| 125 |
+
# request.pending_latent through this lookup (None in
|
| 126 |
+
# multi-process engines, where every latent-row sample is
|
| 127 |
+
# dropped and the exit anchor is forced in the logits).
|
| 128 |
+
self.runner._fork_get_request = self.scheduler.requests.get
|
| 129 |
+
|
| 130 |
+
from processor import processor as proc_mod
|
| 131 |
+
|
| 132 |
+
self._proc = proc_mod
|
| 133 |
+
self.entry_id = proc_mod.special_token_id(
|
| 134 |
+
self.tokenizer, proc_mod.ENTRY_ANCHOR
|
| 135 |
+
)
|
| 136 |
+
self.exit_id = proc_mod.special_token_id(
|
| 137 |
+
self.tokenizer, proc_mod.EXIT_ANCHOR
|
| 138 |
+
)
|
| 139 |
+
proc_mod.register_engine(
|
| 140 |
+
get_request=self.scheduler.requests.get,
|
| 141 |
+
get_req_ids=lambda: list(self.runner.input_batch.req_ids),
|
| 142 |
+
tokenizer=self.tokenizer,
|
| 143 |
+
tool_executor=self.tool_executor,
|
| 144 |
+
inject_token=self.inject_token,
|
| 145 |
+
max_turns=self.max_turns,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
self.barrier_log: list[BarrierEvent] = []
|
| 149 |
+
self.injected: list[tuple[str, int]] = []
|
| 150 |
+
self._pending_clear: set[str] = set()
|
| 151 |
+
self._final: dict[str, object] = {}
|
| 152 |
+
# External request id -> engine-internal id. vLLM 0.30 randomizes
|
| 153 |
+
# the internal id (assign_request_id); scheduler.requests, the
|
| 154 |
+
# worker batch and the state machine are keyed by the internal id,
|
| 155 |
+
# while RequestOutput.request_id carries the external one.
|
| 156 |
+
self._rid_map: dict[str, str] = {}
|
| 157 |
+
|
| 158 |
+
# ------------------------------------------------------------------
|
| 159 |
+
# requests
|
| 160 |
+
# ------------------------------------------------------------------
|
| 161 |
+
def add_request(
|
| 162 |
+
self,
|
| 163 |
+
prompt,
|
| 164 |
+
request_id: str | None = None,
|
| 165 |
+
sampling_params=None,
|
| 166 |
+
**params_kwargs,
|
| 167 |
+
) -> str:
|
| 168 |
+
"""Submit a prompt to the engine; returns the request id.
|
| 169 |
+
|
| 170 |
+
``sampling_params=None`` builds the ``<latent>``-capable
|
| 171 |
+
defaults: greedy decoding (``temperature=0``), no
|
| 172 |
+
penalties/bad-words/structured output (the state machine owns
|
| 173 |
+
token control), and a ``max_tokens`` budget (``params_kwargs``,
|
| 174 |
+
default 2048).
|
| 175 |
+
"""
|
| 176 |
+
from vllm import SamplingParams
|
| 177 |
+
|
| 178 |
+
if sampling_params is None:
|
| 179 |
+
sampling_params = SamplingParams(
|
| 180 |
+
temperature=0.0,
|
| 181 |
+
max_tokens=params_kwargs.pop("max_tokens", 2048),
|
| 182 |
+
**params_kwargs,
|
| 183 |
+
)
|
| 184 |
+
ext_rid = request_id or f"ha-{uuid.uuid4().hex[:12]}"
|
| 185 |
+
internal_rid = self.engine.add_request(ext_rid, prompt, sampling_params)
|
| 186 |
+
self._rid_map[ext_rid] = internal_rid
|
| 187 |
+
return ext_rid
|
| 188 |
+
|
| 189 |
+
def engine_request_id(self, rid: str) -> str:
|
| 190 |
+
"""The engine-internal request id for an external request id
|
| 191 |
+
(the key used by ``scheduler.requests``, the worker batch and the
|
| 192 |
+
state machine)."""
|
| 193 |
+
return self._rid_map[rid]
|
| 194 |
+
|
| 195 |
+
# ------------------------------------------------------------------
|
| 196 |
+
# in-process sequence append (the T4-registered inject_token)
|
| 197 |
+
# ------------------------------------------------------------------
|
| 198 |
+
def inject_token(self, rid: str, tok: int) -> None:
|
| 199 |
+
"""Append ``tok`` to a running sequence (direct request mutation).
|
| 200 |
+
|
| 201 |
+
Exact path (single worker, last rank, synchronous scheduling):
|
| 202 |
+
|
| 203 |
+
1. engine-side sequence: ``request.append_output_token_ids(tok)``
|
| 204 |
+
-- ``_output_token_ids``/``_all_token_ids`` grow, so the
|
| 205 |
+
scheduler counts the token as pending on the next pass and
|
| 206 |
+
the block hashes extend;
|
| 207 |
+
2. worker-side feed buffer: ``input_batch.token_ids_cpu[req_idx,
|
| 208 |
+
pos] = tok`` where ``pos = input_batch.num_tokens_no_spec[
|
| 209 |
+
req_idx]`` -- the next slot the sampler would have written
|
| 210 |
+
this step's sample to -- then advance ``num_tokens_no_spec``
|
| 211 |
+
and mark ``is_token_ids[req_idx, pos] = True`` (a real token
|
| 212 |
+
slot, not a prompt-embed slot);
|
| 213 |
+
3. worker-side cache: ``runner.requests[rid].output_token_ids``
|
| 214 |
+
(penalty bookkeeping; no penalties here, but kept in sync);
|
| 215 |
+
4. fork sample gate: ``request.pending_latent = True`` so the
|
| 216 |
+
fork's exit-row check (``keep = not req.pending_latent``)
|
| 217 |
+
drops this step's own sample -- no double-append of the exit
|
| 218 |
+
anchor;
|
| 219 |
+
5. the run loop clears ``pending_latent`` before the next
|
| 220 |
+
schedule, otherwise the following row would be read as a
|
| 221 |
+
latent ENTRY row (``pending_latent and num_new_tokens > 0``).
|
| 222 |
+
"""
|
| 223 |
+
req = self.scheduler.requests[rid]
|
| 224 |
+
req.append_output_token_ids(tok)
|
| 225 |
+
ib = self.runner.input_batch
|
| 226 |
+
idx = ib.req_id_to_index[rid]
|
| 227 |
+
pos = int(ib.num_tokens_no_spec[idx])
|
| 228 |
+
ib.token_ids_cpu[idx, pos] = tok
|
| 229 |
+
ib.is_token_ids[idx, pos] = True
|
| 230 |
+
ib.num_tokens_no_spec[idx] = pos + 1
|
| 231 |
+
cached = self.runner.requests.get(rid)
|
| 232 |
+
if cached is not None:
|
| 233 |
+
cached.output_token_ids.append(tok)
|
| 234 |
+
req.pending_latent = True
|
| 235 |
+
self._pending_clear.add(rid)
|
| 236 |
+
self.injected.append((rid, int(tok)))
|
| 237 |
+
|
| 238 |
+
# ------------------------------------------------------------------
|
| 239 |
+
# the run loop
|
| 240 |
+
# ------------------------------------------------------------------
|
| 241 |
+
def run(self, max_steps: int = 10_000) -> dict:
|
| 242 |
+
"""Drive all submitted requests to completion.
|
| 243 |
+
|
| 244 |
+
Returns ``{request_id: RequestOutput}`` for the finished
|
| 245 |
+
requests.
|
| 246 |
+
"""
|
| 247 |
+
step_no = 0
|
| 248 |
+
while self.engine.has_unfinished_requests():
|
| 249 |
+
if step_no >= max_steps:
|
| 250 |
+
raise RuntimeError(
|
| 251 |
+
f"orchestrator step cap ({max_steps}) reached with "
|
| 252 |
+
f"{self.engine.get_num_unfinished_requests()} "
|
| 253 |
+
"request(s) unfinished"
|
| 254 |
+
)
|
| 255 |
+
self._pre_step()
|
| 256 |
+
step_no += 1
|
| 257 |
+
outputs = self.engine.step()
|
| 258 |
+
self._post_step(outputs, step_no)
|
| 259 |
+
return dict(self._final)
|
| 260 |
+
|
| 261 |
+
def final_text(self, rid: str) -> str:
|
| 262 |
+
"""The engine-side final text of a finished request."""
|
| 263 |
+
out = self._final.get(rid)
|
| 264 |
+
if out is None or not getattr(out, "outputs", None):
|
| 265 |
+
return ""
|
| 266 |
+
return out.outputs[0].text
|
| 267 |
+
|
| 268 |
+
def shutdown(self) -> None:
|
| 269 |
+
"""Shut the in-process engine core down (frees the GPU)."""
|
| 270 |
+
self.engine.engine_core.shutdown()
|
| 271 |
+
|
| 272 |
+
# -- per-step hooks -------------------------------------------------
|
| 273 |
+
def _pre_step(self) -> None:
|
| 274 |
+
# Clear the exit-row re-arm (set by inject_token) so the next
|
| 275 |
+
# schedule reads a plain token row, not a latent entry row.
|
| 276 |
+
for rid in list(self._pending_clear):
|
| 277 |
+
req = self.scheduler.requests.get(rid)
|
| 278 |
+
if req is not None:
|
| 279 |
+
req.pending_latent = False
|
| 280 |
+
self._pending_clear.discard(rid)
|
| 281 |
+
|
| 282 |
+
def _post_step(self, outputs, step_no: int) -> None:
|
| 283 |
+
for out in outputs:
|
| 284 |
+
rid = out.request_id # external id
|
| 285 |
+
if out.finished:
|
| 286 |
+
self._final[rid] = out
|
| 287 |
+
internal = self._rid_map.get(rid, rid)
|
| 288 |
+
self._service_barrier(rid, internal, step_no)
|
| 289 |
+
self._arm_entry(internal)
|
| 290 |
+
self._finish_if_done(rid, internal)
|
| 291 |
+
|
| 292 |
+
def _service_barrier(self, rid: str, internal: str, step_no: int) -> None:
|
| 293 |
+
"""Tool barrier: run the pending tool and re-inject the result."""
|
| 294 |
+
st = self._proc.get_state(internal)
|
| 295 |
+
if st is None or st.mode != "IDLE" or st.pending_tool is None:
|
| 296 |
+
return
|
| 297 |
+
tag, content = st.pending_tool
|
| 298 |
+
result = self.tool_executor(tag, content)
|
| 299 |
+
self.barrier_log.append(
|
| 300 |
+
BarrierEvent(
|
| 301 |
+
request_id=rid,
|
| 302 |
+
tag=tag,
|
| 303 |
+
content=content,
|
| 304 |
+
result=result,
|
| 305 |
+
step=step_no,
|
| 306 |
+
)
|
| 307 |
+
)
|
| 308 |
+
self._proc.record_tool_call(internal, tag)
|
| 309 |
+
obs_ids = self.tokenizer(
|
| 310 |
+
f"<result>{result}</result>", add_special_tokens=False
|
| 311 |
+
)["input_ids"]
|
| 312 |
+
self._proc.queue_observation(internal, list(obs_ids))
|
| 313 |
+
|
| 314 |
+
def _arm_entry(self, rid: str) -> None:
|
| 315 |
+
"""Pre-arm pending_latent once <latent> lands in the output.
|
| 316 |
+
|
| 317 |
+
The state machine's own write happens one step later (it detects
|
| 318 |
+
the entry from the sampled token on the next apply); the fork's
|
| 319 |
+
entry row is the step that PROCESSES the anchor, so the flag
|
| 320 |
+
must be set before that row is scheduled.
|
| 321 |
+
"""
|
| 322 |
+
st = self._proc.get_state(rid)
|
| 323 |
+
req = self.scheduler.requests.get(rid)
|
| 324 |
+
if st is None or req is None:
|
| 325 |
+
return
|
| 326 |
+
if (
|
| 327 |
+
st.mode == "DECODE"
|
| 328 |
+
and req._output_token_ids
|
| 329 |
+
and req._output_token_ids[-1] == self.entry_id
|
| 330 |
+
and not req.pending_latent
|
| 331 |
+
):
|
| 332 |
+
req.pending_latent = True
|
| 333 |
+
|
| 334 |
+
def _finish_if_done(self, rid: str, internal: str) -> None:
|
| 335 |
+
"""Abort the engine request once the state machine is DONE.
|
| 336 |
+
|
| 337 |
+
The reference engine stops at DONE (max_turns, fabricated
|
| 338 |
+
<result>, budget); vLLM would otherwise keep sampling until its
|
| 339 |
+
own eos/max_tokens.
|
| 340 |
+
"""
|
| 341 |
+
st = self._proc.get_state(internal)
|
| 342 |
+
if st is None or st.mode != "DONE":
|
| 343 |
+
return
|
| 344 |
+
req = self.scheduler.requests.get(internal)
|
| 345 |
+
if req is None:
|
| 346 |
+
return
|
| 347 |
+
from vllm.v1.request import RequestStatus
|
| 348 |
+
|
| 349 |
+
if req.status == RequestStatus.RUNNING:
|
| 350 |
+
self.engine.abort_request([rid])
|
serving/orchestrator/pyproject.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "hybridagent-orchestrator"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "External orchestrator: drives the forked in-process vLLM engine and runs the HybridAgent tool barrier (T5)"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
# No entry points: this package is imported by the serving process. The
|
| 11 |
+
# engine-side plugins are the T3 backend (vllm.general_plugins) and the
|
| 12 |
+
# T4 state machine (vllm.logits_processors).
|
| 13 |
+
|
| 14 |
+
[tool.setuptools]
|
| 15 |
+
packages = ["orchestrator"]
|
serving/orchestrator/tools.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool executors for the HybridAgent tool barrier (T5).
|
| 2 |
+
|
| 3 |
+
Reference parity (togethercomputer/HybridAgent ``ToolExecutor``): one tool
|
| 4 |
+
call is ``(tag, content) -> result text``. The orchestrator wraps the
|
| 5 |
+
returned text in ``<result>...</result>``, tokenizes it and re-injects it
|
| 6 |
+
as FEED tokens for the paused request.
|
| 7 |
+
|
| 8 |
+
Two tools (``tag`` in {"python", "search"}, the state machine's
|
| 9 |
+
``TOOL_TAGS``):
|
| 10 |
+
|
| 11 |
+
* ``python`` -- runs ``content`` as Python in a bounded, isolated
|
| 12 |
+
interpreter (``python -I``: no PYTHONPATH, no environment, no user
|
| 13 |
+
site-packages), with a wall-clock timeout and a capped output. Returns
|
| 14 |
+
the captured stdout, or an ``ERROR: ...`` string on failure/timeout.
|
| 15 |
+
This is a resource boundary (time, output, environment isolation), not
|
| 16 |
+
a security sandbox: the code still runs with the process's filesystem
|
| 17 |
+
permissions.
|
| 18 |
+
* ``search`` -- web search via the Tavily API when ``TAVILY_API_KEY`` is
|
| 19 |
+
set in the environment; otherwise a clear not-configured stub string.
|
| 20 |
+
No network activity happens without the key.
|
| 21 |
+
|
| 22 |
+
Both return plain strings (the reference contract).
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
import subprocess
|
| 29 |
+
import sys
|
| 30 |
+
import urllib.error
|
| 31 |
+
import urllib.request
|
| 32 |
+
|
| 33 |
+
__all__ = [
|
| 34 |
+
"ToolExecutor",
|
| 35 |
+
"make_default_tool_executor",
|
| 36 |
+
"run_python",
|
| 37 |
+
"run_search",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
PYTHON_TIMEOUT_S = 10.0
|
| 41 |
+
PYTHON_MAX_OUTPUT = 8192
|
| 42 |
+
SEARCH_TIMEOUT_S = 20.0
|
| 43 |
+
SEARCH_MAX_OUTPUT = 8192
|
| 44 |
+
TAVILY_ENDPOINT = "https://api.tavily.com/search"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _truncate(text: str, limit: int) -> str:
|
| 48 |
+
return text if len(text) <= limit else text[:limit] + "...<truncated>"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def run_python(
|
| 52 |
+
code: str,
|
| 53 |
+
*,
|
| 54 |
+
timeout: float = PYTHON_TIMEOUT_S,
|
| 55 |
+
max_output: int = PYTHON_MAX_OUTPUT,
|
| 56 |
+
) -> str:
|
| 57 |
+
"""Run ``code`` in a bounded, isolated interpreter; return a string."""
|
| 58 |
+
if not isinstance(code, str) or not code.strip():
|
| 59 |
+
return "ERROR: empty python code"
|
| 60 |
+
env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")}
|
| 61 |
+
try:
|
| 62 |
+
proc = subprocess.run(
|
| 63 |
+
[sys.executable, "-I", "-c", code],
|
| 64 |
+
capture_output=True,
|
| 65 |
+
text=True,
|
| 66 |
+
timeout=timeout,
|
| 67 |
+
env=env,
|
| 68 |
+
)
|
| 69 |
+
except subprocess.TimeoutExpired:
|
| 70 |
+
return f"ERROR: python tool timed out after {timeout:.0f}s"
|
| 71 |
+
out = (proc.stdout or "").strip()
|
| 72 |
+
if proc.returncode != 0:
|
| 73 |
+
err_lines = (proc.stderr or "").strip().splitlines()
|
| 74 |
+
last = err_lines[-1] if err_lines else f"exit code {proc.returncode}"
|
| 75 |
+
return f"ERROR: {last}"
|
| 76 |
+
return _truncate(out, max_output)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def run_search(
|
| 80 |
+
query: str,
|
| 81 |
+
*,
|
| 82 |
+
timeout: float = SEARCH_TIMEOUT_S,
|
| 83 |
+
max_output: int = SEARCH_MAX_OUTPUT,
|
| 84 |
+
) -> str:
|
| 85 |
+
"""Web search via Tavily when configured; a stub string otherwise."""
|
| 86 |
+
key = os.environ.get("TAVILY_API_KEY", "").strip()
|
| 87 |
+
if not key:
|
| 88 |
+
return (
|
| 89 |
+
"web_search not configured: TAVILY_API_KEY is not set; no results."
|
| 90 |
+
)
|
| 91 |
+
if not isinstance(query, str) or not query.strip():
|
| 92 |
+
return "ERROR: empty search query"
|
| 93 |
+
body = json.dumps(
|
| 94 |
+
{"api_key": key, "query": query, "max_results": 3}
|
| 95 |
+
).encode("utf-8")
|
| 96 |
+
req = urllib.request.Request(
|
| 97 |
+
TAVILY_ENDPOINT,
|
| 98 |
+
data=body,
|
| 99 |
+
headers={"Content-Type": "application/json"},
|
| 100 |
+
)
|
| 101 |
+
try:
|
| 102 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 103 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 104 |
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
| 105 |
+
return f"ERROR: search failed: {exc}"
|
| 106 |
+
parts: list[str] = []
|
| 107 |
+
if data.get("answer"):
|
| 108 |
+
parts.append(str(data["answer"]))
|
| 109 |
+
for result in (data.get("results") or [])[:3]:
|
| 110 |
+
title = result.get("title") or ""
|
| 111 |
+
content = result.get("content") or ""
|
| 112 |
+
parts.append(f"{title}: {content}" if title else content)
|
| 113 |
+
out = "\n".join(p for p in parts if p).strip()
|
| 114 |
+
return _truncate(out, max_output) if out else "no results"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class ToolExecutor:
|
| 118 |
+
"""``(tag, content) -> result text`` for the orchestrator's tool barrier."""
|
| 119 |
+
|
| 120 |
+
def __init__(self, python=None, search=None) -> None:
|
| 121 |
+
self.python = python if python is not None else run_python
|
| 122 |
+
self.search = search if search is not None else run_search
|
| 123 |
+
|
| 124 |
+
def __call__(self, tag: str, content: str) -> str:
|
| 125 |
+
if tag == "python":
|
| 126 |
+
return self.python(content)
|
| 127 |
+
if tag == "search":
|
| 128 |
+
return self.search(content)
|
| 129 |
+
return f"ERROR: unknown tool {tag!r}"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def make_default_tool_executor() -> ToolExecutor:
|
| 133 |
+
"""The default barrier executor (bounded python + Tavily/stub search)."""
|
| 134 |
+
return ToolExecutor()
|
serving/processor/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HybridAgent vLLM stateful logits processor (T4).
|
| 2 |
+
|
| 3 |
+
The DECODE/LATENT/FEED/IDLE/DONE generation state machine from
|
| 4 |
+
togethercomputer/HybridAgent running inside vLLM as a stateful logits
|
| 5 |
+
processor (``vllm.logits_processors`` entry-point group). Drives the
|
| 6 |
+
fork's zero-token latent steps via ``request.pending_latent`` and
|
| 7 |
+
forces tokens (latent exit anchor, FEED observations) via logits
|
| 8 |
+
masking.
|
| 9 |
+
|
| 10 |
+
Install into the serving venv to activate:
|
| 11 |
+
|
| 12 |
+
pip install -e /opt/hybridagent-vllm/processor
|
| 13 |
+
|
| 14 |
+
T5 (the external orchestrator) arms it once, in the engine process:
|
| 15 |
+
|
| 16 |
+
import processor
|
| 17 |
+
processor.register_engine(
|
| 18 |
+
get_request=..., # engine-side Request by req_id
|
| 19 |
+
get_req_ids=..., # current worker batch order
|
| 20 |
+
tokenizer=..., # <latent>/</latent> resolved at runtime
|
| 21 |
+
tool_executor=..., # (tag, content) -> result text
|
| 22 |
+
inject_token=..., # in-process sequence append (exit anchor)
|
| 23 |
+
max_turns=30,
|
| 24 |
+
)
|
| 25 |
+
# at each tool barrier (request paused in IDLE):
|
| 26 |
+
tag, content = processor.get_state(req_id).pending_tool
|
| 27 |
+
result = tool_executor(tag, content)
|
| 28 |
+
processor.queue_observation(
|
| 29 |
+
req_id,
|
| 30 |
+
tokenizer(f"<result>{result}</result>",
|
| 31 |
+
add_special_tokens=False)["input_ids"],
|
| 32 |
+
)
|
| 33 |
+
"""
|
| 34 |
+
from processor.processor import (
|
| 35 |
+
HybridAgentLogitsProcessor,
|
| 36 |
+
get_state,
|
| 37 |
+
queue_observation,
|
| 38 |
+
register_engine,
|
| 39 |
+
record_tool_call,
|
| 40 |
+
)
|
| 41 |
+
from processor.state_machine import (
|
| 42 |
+
DECODE,
|
| 43 |
+
DONE,
|
| 44 |
+
FEED,
|
| 45 |
+
IDLE,
|
| 46 |
+
LATENT,
|
| 47 |
+
RequestState,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
__all__ = [
|
| 51 |
+
"HybridAgentLogitsProcessor",
|
| 52 |
+
"register_engine",
|
| 53 |
+
"queue_observation",
|
| 54 |
+
"record_tool_call",
|
| 55 |
+
"get_state",
|
| 56 |
+
"RequestState",
|
| 57 |
+
"DECODE",
|
| 58 |
+
"LATENT",
|
| 59 |
+
"FEED",
|
| 60 |
+
"IDLE",
|
| 61 |
+
"DONE",
|
| 62 |
+
]
|
serving/processor/processor.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""vLLM stateful logits processor: the HybridAgent state machine (T4).
|
| 2 |
+
|
| 3 |
+
Wired through the ``vllm.logits_processors`` entry-point group (see
|
| 4 |
+
pyproject.toml):
|
| 5 |
+
|
| 6 |
+
[project.entry-points."vllm.logits_processors"]
|
| 7 |
+
hybridagent_state_machine = "processor.processor:HybridAgentLogitsProcessor"
|
| 8 |
+
|
| 9 |
+
The processor mirrors the ``AdapterLogitsProcessor`` pattern
|
| 10 |
+
(``vllm/v1/sample/logits_processor/``): a batch-index -> state map
|
| 11 |
+
maintained from ``BatchUpdate`` (the ``output_tok_ids`` entries are
|
| 12 |
+
live references to each request's running output list), advanced in
|
| 13 |
+
``apply()`` from the live token stream and the current logits' argmax.
|
| 14 |
+
|
| 15 |
+
Fork integration (branch ``hybridagent-latent-fork``):
|
| 16 |
+
|
| 17 |
+
* ``request.pending_latent`` -- written on the engine-side ``Request``
|
| 18 |
+
(resolved through the T5-registered request store) so the fork's
|
| 19 |
+
scheduler gate (``scheduler.py``: ``pending_latent and
|
| 20 |
+
num_new_tokens == 0 -> latent_positions = 1``) schedules the
|
| 21 |
+
zero-token latent step. vLLM 0.30's ``BatchUpdate`` carries only
|
| 22 |
+
``SamplingParams`` + output-token references and its
|
| 23 |
+
``ForwardContext`` exposes no model-runner handle, so the engine-side
|
| 24 |
+
request is reached through the in-process store T5 registers (the T5
|
| 25 |
+
deployment is the in-process ``LLMEngine``).
|
| 26 |
+
* Forced tokens -- the logits row is masked to a single id (latent
|
| 27 |
+
exit anchor fallback, FEED observation tokens).
|
| 28 |
+
* Exit-anchor injection -- in-process engines: T5-registered
|
| 29 |
+
``inject_token(req_id, token_id)`` appends ``</latent>`` to the
|
| 30 |
+
sequence at a latent exit (reference parity: ``<latent>``, n latent
|
| 31 |
+
forwards, ``</latent>``).
|
| 32 |
+
|
| 33 |
+
T4b -- reference ``blocked_token_sets`` (engine.py:140-149): every
|
| 34 |
+
DECODE (text) logits row is masked to ``-inf`` at the blocked anchor
|
| 35 |
+
ids, from the first step: in ``<think>`` block ``</latent>`` (and
|
| 36 |
+
``<think>``), out of ``<think>`` block ``</latent>``, ``<latent>``
|
| 37 |
+
(and ``</think>``). ``in_think`` is initialized from the prompt
|
| 38 |
+
(engine.py:221) and tracked per request from the generated
|
| 39 |
+
``<think>``/``</think>`` tokens; LATENT exit readouts stay raw. The
|
| 40 |
+
masks are applied before the single-token forcing, so FEED/exit
|
| 41 |
+
forcing is unchanged. Non-agent requests still get no
|
| 42 |
+
``pending_latent`` writes.
|
| 43 |
+
"""
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
from dataclasses import dataclass, field
|
| 47 |
+
from typing import TYPE_CHECKING, Any, Callable
|
| 48 |
+
|
| 49 |
+
import torch
|
| 50 |
+
|
| 51 |
+
from vllm import SamplingParams
|
| 52 |
+
from vllm.v1.sample.logits_processor import (
|
| 53 |
+
BatchUpdate,
|
| 54 |
+
LogitsProcessor,
|
| 55 |
+
process_dict_updates,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
from processor.state_machine import (
|
| 59 |
+
DEFAULT_MAX_TURNS,
|
| 60 |
+
ENTRY_ANCHOR,
|
| 61 |
+
EXIT_ANCHOR,
|
| 62 |
+
THINK_CLOSE,
|
| 63 |
+
THINK_OPEN,
|
| 64 |
+
TOOL_TAG_TAIL_TOKENS,
|
| 65 |
+
RequestState,
|
| 66 |
+
StepAction,
|
| 67 |
+
advance,
|
| 68 |
+
queue_observation as _sm_queue_observation,
|
| 69 |
+
record_tool_call as _sm_record_tool_call,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
if TYPE_CHECKING:
|
| 73 |
+
from vllm.config import VllmConfig
|
| 74 |
+
|
| 75 |
+
__all__ = [
|
| 76 |
+
"HybridAgentLogitsProcessor",
|
| 77 |
+
"register_engine",
|
| 78 |
+
"queue_observation",
|
| 79 |
+
"record_tool_call",
|
| 80 |
+
"get_state",
|
| 81 |
+
"special_token_id",
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
# Live processor instances in this process (one worker, one instance).
|
| 85 |
+
_PROCESSORS: list["HybridAgentLogitsProcessor"] = []
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def special_token_id(tokenizer: Any, token: str) -> int:
|
| 89 |
+
"""Id of a special token; fails loudly if the tokenizer does not know
|
| 90 |
+
it (reference ``constants.special_token_id``)."""
|
| 91 |
+
tok_id = tokenizer.convert_tokens_to_ids(token)
|
| 92 |
+
unk_id = getattr(tokenizer, "unk_token_id", None)
|
| 93 |
+
if tok_id is None or (unk_id is not None and tok_id == unk_id):
|
| 94 |
+
raise ValueError(
|
| 95 |
+
f"Tokenizer does not know the special token {token!r}; "
|
| 96 |
+
"load a HybridAgent checkpoint or add the anchor tokens first."
|
| 97 |
+
)
|
| 98 |
+
return int(tok_id)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _optional_token_id(tokenizer: Any, token: str) -> int | None:
|
| 102 |
+
"""T4b: token id or ``None`` when the tokenizer does not know it.
|
| 103 |
+
|
| 104 |
+
The think-tag guards (reference ``think_guards``) degrade to
|
| 105 |
+
anchor-only masks in that case (reference ``think_guards=False``
|
| 106 |
+
path); they never block arming the engine.
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
tok_id = tokenizer.convert_tokens_to_ids(token)
|
| 110 |
+
except Exception:
|
| 111 |
+
return None
|
| 112 |
+
unk_id = getattr(tokenizer, "unk_token_id", None)
|
| 113 |
+
if tok_id is None or (unk_id is not None and tok_id == unk_id):
|
| 114 |
+
return None
|
| 115 |
+
return int(tok_id)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@dataclass
|
| 119 |
+
class EngineHandles:
|
| 120 |
+
"""Process-level handles T5 registers once (in-process engine)."""
|
| 121 |
+
|
| 122 |
+
get_request: Callable[[str], Any] | None = None
|
| 123 |
+
get_req_ids: Callable[[], list[str] | None] | None = None
|
| 124 |
+
tokenizer: Any | None = None
|
| 125 |
+
tool_executor: Callable[[str, str], str] | None = None
|
| 126 |
+
inject_token: Callable[[str, int], None] | None = None
|
| 127 |
+
max_turns: int = DEFAULT_MAX_TURNS
|
| 128 |
+
entry_id: int | None = None
|
| 129 |
+
exit_id: int | None = None
|
| 130 |
+
think_open_id: int | None = None
|
| 131 |
+
think_close_id: int | None = None
|
| 132 |
+
eos_token_ids: frozenset[int] = field(default_factory=frozenset)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
_engine: EngineHandles | None = None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def register_engine(
|
| 139 |
+
*,
|
| 140 |
+
get_request: Callable[[str], Any] | None = None,
|
| 141 |
+
get_req_ids: Callable[[], list[str] | None] | None = None,
|
| 142 |
+
tokenizer: Any | None = None,
|
| 143 |
+
tool_executor: Callable[[str, str], str] | None = None,
|
| 144 |
+
inject_token: Callable[[str, int], None] | None = None,
|
| 145 |
+
max_turns: int = DEFAULT_MAX_TURNS,
|
| 146 |
+
latent_open_id: int | None = None,
|
| 147 |
+
latent_close_id: int | None = None,
|
| 148 |
+
) -> None:
|
| 149 |
+
"""T5 interface: arm the state machine in this process.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
get_request: engine-side ``Request`` by req_id (the object the
|
| 153 |
+
fork's scheduler reads ``pending_latent`` from).
|
| 154 |
+
get_req_ids: current worker batch order (logits row i is
|
| 155 |
+
``req_ids[i]``).
|
| 156 |
+
tokenizer: the HybridAgent tokenizer; the ``<latent>`` /
|
| 157 |
+
``</latent>`` ids are resolved from it at runtime (explicit
|
| 158 |
+
``latent_open_id`` / ``latent_close_id`` override). The
|
| 159 |
+
T4b think-tag ids (``<think>`` / ``</think>``) are resolved
|
| 160 |
+
leniently: unknown tag -> anchor-only masks.
|
| 161 |
+
tool_executor: ``(tag, content) -> result text`` for one
|
| 162 |
+
pending tool call (tag in {"python", "search"}).
|
| 163 |
+
inject_token: in-process sequence append used to feed the exit
|
| 164 |
+
anchor at a latent exit (reference parity).
|
| 165 |
+
max_turns: the reference turn cap (default 30).
|
| 166 |
+
"""
|
| 167 |
+
global _engine
|
| 168 |
+
entry_id = latent_open_id
|
| 169 |
+
exit_id = latent_close_id
|
| 170 |
+
think_open: int | None = None
|
| 171 |
+
think_close: int | None = None
|
| 172 |
+
eos: set[int] = set()
|
| 173 |
+
if tokenizer is not None:
|
| 174 |
+
if entry_id is None:
|
| 175 |
+
entry_id = special_token_id(tokenizer, ENTRY_ANCHOR)
|
| 176 |
+
if exit_id is None:
|
| 177 |
+
exit_id = special_token_id(tokenizer, EXIT_ANCHOR)
|
| 178 |
+
# T4b: think-tag ids for the blocked-set guards (lenient:
|
| 179 |
+
# unknown tag -> anchor-only masks).
|
| 180 |
+
think_open = _optional_token_id(tokenizer, THINK_OPEN)
|
| 181 |
+
think_close = _optional_token_id(tokenizer, THINK_CLOSE)
|
| 182 |
+
cand = getattr(tokenizer, "eos_token_id", None)
|
| 183 |
+
if cand is not None:
|
| 184 |
+
eos.update(cand if isinstance(cand, (list, tuple)) else [cand])
|
| 185 |
+
if entry_id is None or exit_id is None:
|
| 186 |
+
raise ValueError(
|
| 187 |
+
"register_engine: the <latent>/</latent> ids must be "
|
| 188 |
+
"resolvable from the tokenizer (or passed explicitly)."
|
| 189 |
+
)
|
| 190 |
+
_engine = EngineHandles(
|
| 191 |
+
get_request=get_request,
|
| 192 |
+
get_req_ids=get_req_ids,
|
| 193 |
+
tokenizer=tokenizer,
|
| 194 |
+
tool_executor=tool_executor,
|
| 195 |
+
inject_token=inject_token,
|
| 196 |
+
max_turns=max_turns,
|
| 197 |
+
entry_id=int(entry_id),
|
| 198 |
+
exit_id=int(exit_id),
|
| 199 |
+
think_open_id=think_open,
|
| 200 |
+
think_close_id=think_close,
|
| 201 |
+
eos_token_ids=frozenset(int(i) for i in eos),
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _find_state(req_id: str) -> RequestState | None:
|
| 206 |
+
eng = _engine
|
| 207 |
+
if eng is None or eng.get_req_ids is None:
|
| 208 |
+
return None
|
| 209 |
+
req_ids = eng.get_req_ids()
|
| 210 |
+
if req_ids is None or req_id not in req_ids:
|
| 211 |
+
return None
|
| 212 |
+
idx = req_ids.index(req_id)
|
| 213 |
+
for proc in _PROCESSORS:
|
| 214 |
+
if idx in proc.info:
|
| 215 |
+
return proc.info[idx][0]
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def queue_observation(req_id: str, token_ids: list[int]) -> None:
|
| 220 |
+
"""T5 tool barrier: feed a ``<result>...`` observation to a request
|
| 221 |
+
paused in IDLE (empty list = empty observation)."""
|
| 222 |
+
state = _find_state(req_id)
|
| 223 |
+
if state is None:
|
| 224 |
+
raise KeyError(f"no tracked HybridAgent state for request {req_id!r}")
|
| 225 |
+
_sm_queue_observation(state, token_ids)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def record_tool_call(req_id: str, tag: str) -> None:
|
| 229 |
+
"""T5: report an executed tool call (per-tag counter bookkeeping)."""
|
| 230 |
+
state = _find_state(req_id)
|
| 231 |
+
if state is None:
|
| 232 |
+
raise KeyError(f"no tracked HybridAgent state for request {req_id!r}")
|
| 233 |
+
_sm_record_tool_call(state, tag)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def get_state(req_id: str) -> RequestState | None:
|
| 237 |
+
"""T5: read a request's state (mode, pending_tool, end_reason,
|
| 238 |
+
tool counters)."""
|
| 239 |
+
return _find_state(req_id)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class HybridAgentLogitsProcessor(LogitsProcessor):
|
| 243 |
+
"""Stateful logits processor running the HybridAgent state machine."""
|
| 244 |
+
|
| 245 |
+
def __init__(
|
| 246 |
+
self,
|
| 247 |
+
vllm_config: "VllmConfig",
|
| 248 |
+
device: torch.device,
|
| 249 |
+
is_pin_memory: bool,
|
| 250 |
+
):
|
| 251 |
+
self.device = device
|
| 252 |
+
# batch index -> (state, live output-token list, max_tokens,
|
| 253 |
+
# stop ids)
|
| 254 |
+
self.info: dict[
|
| 255 |
+
int, tuple[RequestState, list[int], int | None, frozenset[int]]
|
| 256 |
+
] = {}
|
| 257 |
+
_PROCESSORS.append(self)
|
| 258 |
+
|
| 259 |
+
def is_argmax_invariant(self) -> bool:
|
| 260 |
+
# Forced tokens (latent exit, FEED) change greedy argmax.
|
| 261 |
+
return False
|
| 262 |
+
|
| 263 |
+
@staticmethod
|
| 264 |
+
def _new_info(
|
| 265 |
+
params: SamplingParams,
|
| 266 |
+
prompt_ids: list[int] | None,
|
| 267 |
+
output_ids: list[int],
|
| 268 |
+
) -> tuple[RequestState, list[int], int | None, frozenset[int]]:
|
| 269 |
+
max_turns = _engine.max_turns if _engine is not None else DEFAULT_MAX_TURNS
|
| 270 |
+
state = RequestState(max_turns=max_turns)
|
| 271 |
+
# T4b: reference in_think initialization (engine.py:221/239) --
|
| 272 |
+
# the chat template ends in the <think> token.
|
| 273 |
+
eng = _engine
|
| 274 |
+
if eng is not None and eng.tokenizer is not None and prompt_ids:
|
| 275 |
+
prompt_text = eng.tokenizer.decode(
|
| 276 |
+
list(prompt_ids), skip_special_tokens=False
|
| 277 |
+
)
|
| 278 |
+
state.in_think = prompt_text.rstrip("\n").endswith(THINK_OPEN)
|
| 279 |
+
return (
|
| 280 |
+
state,
|
| 281 |
+
output_ids,
|
| 282 |
+
params.max_tokens,
|
| 283 |
+
frozenset(params.all_stop_token_ids),
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
def update_state(self, batch_update: BatchUpdate | None):
|
| 287 |
+
process_dict_updates(self.info, batch_update, self._new_info)
|
| 288 |
+
|
| 289 |
+
def apply(self, logits: torch.Tensor) -> torch.Tensor:
|
| 290 |
+
eng = _engine
|
| 291 |
+
if not self.info or eng is None or eng.get_req_ids is None:
|
| 292 |
+
return logits
|
| 293 |
+
req_ids = eng.get_req_ids()
|
| 294 |
+
if req_ids is None:
|
| 295 |
+
return logits
|
| 296 |
+
tracked = [idx for idx in self.info if idx < len(req_ids)]
|
| 297 |
+
if not tracked:
|
| 298 |
+
return logits
|
| 299 |
+
argmax_ids = torch.argmax(logits[tracked], dim=-1).tolist()
|
| 300 |
+
|
| 301 |
+
forced: list[tuple[int, int]] = []
|
| 302 |
+
masked: list[tuple[int, frozenset[int]]] = []
|
| 303 |
+
for idx, argmax_id in zip(tracked, argmax_ids):
|
| 304 |
+
st, outputs, max_tokens, stop_ids = self.info[idx]
|
| 305 |
+
last_token = outputs[-1] if outputs else None
|
| 306 |
+
tail = ""
|
| 307 |
+
full_text_getter = None
|
| 308 |
+
if st.mode == "DECODE" and eng.tokenizer is not None:
|
| 309 |
+
# Reference order: append the just-sampled token to the
|
| 310 |
+
# text-since-resume window, then take the tail of the last
|
| 311 |
+
# ~50 *text* tokens (observations/anchors excluded).
|
| 312 |
+
window = list(st.text_ids_since_resume)
|
| 313 |
+
if last_token is not None and last_token != eng.exit_id:
|
| 314 |
+
window.append(last_token)
|
| 315 |
+
if window:
|
| 316 |
+
tail = eng.tokenizer.decode(
|
| 317 |
+
window[-TOOL_TAG_TAIL_TOKENS:],
|
| 318 |
+
skip_special_tokens=False,
|
| 319 |
+
)
|
| 320 |
+
full_text_getter = eng.tokenizer.decode
|
| 321 |
+
|
| 322 |
+
action = advance(
|
| 323 |
+
st,
|
| 324 |
+
last_token=last_token,
|
| 325 |
+
argmax_id=argmax_id,
|
| 326 |
+
tail_text=tail,
|
| 327 |
+
full_text_getter=(
|
| 328 |
+
(lambda: full_text_getter(outputs,
|
| 329 |
+
skip_special_tokens=False))
|
| 330 |
+
if tail
|
| 331 |
+
else None
|
| 332 |
+
),
|
| 333 |
+
entry_id=eng.entry_id,
|
| 334 |
+
exit_id=eng.exit_id,
|
| 335 |
+
stop_ids=stop_ids | eng.eos_token_ids,
|
| 336 |
+
max_tokens=max_tokens,
|
| 337 |
+
output_len=len(outputs),
|
| 338 |
+
think_open_id=eng.think_open_id,
|
| 339 |
+
think_close_id=eng.think_close_id,
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
req = (
|
| 343 |
+
eng.get_request(req_ids[idx])
|
| 344 |
+
if eng.get_request is not None
|
| 345 |
+
else None
|
| 346 |
+
)
|
| 347 |
+
if req is not None and st.dirty:
|
| 348 |
+
req.pending_latent = action.pending_latent
|
| 349 |
+
if action.inject_token is not None and eng.inject_token is not None:
|
| 350 |
+
eng.inject_token(req_ids[idx], action.inject_token)
|
| 351 |
+
action = StepAction(pending_latent=action.pending_latent)
|
| 352 |
+
if action.blocked_ids:
|
| 353 |
+
masked.append((idx, action.blocked_ids))
|
| 354 |
+
if action.force_id is not None:
|
| 355 |
+
forced.append((idx, action.force_id))
|
| 356 |
+
|
| 357 |
+
# T4b: reference text-mode anchor masks (engine.py:443-451 +
|
| 358 |
+
# sampling.py:75 masked_fill): block </latent> in all text
|
| 359 |
+
# mode, <latent> outside <think> (plus the think-tag guards).
|
| 360 |
+
# Applied before the forcing loop so forced tokens (FEED,
|
| 361 |
+
# latent exit) still win on their own rows.
|
| 362 |
+
if masked:
|
| 363 |
+
dev = logits.device
|
| 364 |
+
for idx, ids in masked:
|
| 365 |
+
logits[idx, torch.tensor(sorted(ids), device=dev,
|
| 366 |
+
dtype=torch.long)] = float("-inf")
|
| 367 |
+
for idx, tok in forced:
|
| 368 |
+
logits[idx].fill_(float("-inf"))
|
| 369 |
+
logits[idx, tok] = 0.0
|
| 370 |
+
return logits
|
serving/processor/pyproject.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "hybridagent-vllm-processor"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "vLLM stateful logits processor for HybridAgent: DECODE/LATENT/FEED/IDLE/DONE state machine + forced </latent> (zero-token latent steps)"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
# vllm and torch are intentionally not declared: they come from the
|
| 11 |
+
# serving environment, mirroring hybridagent-vllm-backend.
|
| 12 |
+
dependencies = []
|
| 13 |
+
|
| 14 |
+
[project.entry-points."vllm.logits_processors"]
|
| 15 |
+
hybridagent_state_machine = "processor.processor:HybridAgentLogitsProcessor"
|
| 16 |
+
|
| 17 |
+
[tool.setuptools]
|
| 18 |
+
# Flat layout: the `processor` package files (__init__.py, processor.py,
|
| 19 |
+
# state_machine.py) live in the project root alongside pyproject.toml, not in
|
| 20 |
+
# a nested processor/processor/ dir. Map the package to "." so the
|
| 21 |
+
# vllm.logits_processors entry point (processor.processor:HybridAgentLogitsProcessor)
|
| 22 |
+
# resolves after an editable install.
|
| 23 |
+
package-dir = {"processor" = "."}
|
| 24 |
+
packages = ["processor"]
|
serving/processor/state_machine.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HybridAgent generation state machine (T4) -- pure control flow.
|
| 2 |
+
|
| 3 |
+
Mirrors the control flow of togethercomputer/HybridAgent's
|
| 4 |
+
``HybridGenerationEngine`` (``hybrid_agent/engine.py`` @ HEAD 1b41cebb):
|
| 5 |
+
the DECODE/LATENT/FEED/IDLE/DONE mode machine, latent entry/exit
|
| 6 |
+
anchors, the tool barrier, observation re-injection, and the turn cap.
|
| 7 |
+
|
| 8 |
+
T4b -- reference ``blocked_token_sets`` port (engine.py:140-149):
|
| 9 |
+
every DECODE step now carries the text-mode anchor masks
|
| 10 |
+
(``StepAction.blocked_ids``): in ``<think>`` block
|
| 11 |
+
``{</latent>, <think>}``, out of ``<think>`` block
|
| 12 |
+
``{</latent>, <latent>, </think>}`` (guards active when the think-tag
|
| 13 |
+
ids are known, as in the reference default). ``in_think`` is tracked
|
| 14 |
+
per request from the ``<think>``/``</think>`` tokens in the generated
|
| 15 |
+
sequence (reference engine.py:493-496), initialized from the prompt
|
| 16 |
+
(engine.py:221/239) and reset when an observation is fully fed
|
| 17 |
+
(engine.py:434/590). LATENT exit readouts stay raw (engine.py:373),
|
| 18 |
+
so ``</latent>`` can only be produced by the forced latent-exit
|
| 19 |
+
token and ``<latent>`` only inside ``<think>``.
|
| 20 |
+
|
| 21 |
+
No vLLM/torch dependency: the vLLM-side glue (logits masking,
|
| 22 |
+
``request.pending_latent`` writes, entry-point wiring) lives in
|
| 23 |
+
``processor.processor``. This module is what the unit test
|
| 24 |
+
(``poc/test_state_machine.py``) drives directly.
|
| 25 |
+
|
| 26 |
+
Per-step contract -- the caller (the logits processor) invokes
|
| 27 |
+
``advance()`` once per generation step, after the forward pass and
|
| 28 |
+
before sampling, with:
|
| 29 |
+
|
| 30 |
+
* ``last_token`` -- the token sampled at the previous step (``None``
|
| 31 |
+
before the first sample; unchanged across zero-token latent steps,
|
| 32 |
+
since the fork drops latent-row samples);
|
| 33 |
+
* ``argmax_id`` -- the argmax of the current step's logits row;
|
| 34 |
+
* ``tail_text`` -- decoded text of the last ~50 text tokens (the
|
| 35 |
+
reference tool-tag window);
|
| 36 |
+
* ``full_text_getter`` -- lazily decoded full output text, used only
|
| 37 |
+
when a tool tag fires (content extraction, reference semantics).
|
| 38 |
+
|
| 39 |
+
Latent-phase timing (reference parity): the exit decision that the
|
| 40 |
+
reference makes at the *start* of a step (previous step's logits,
|
| 41 |
+
``latent_steps >= 4``) is made here at the *end* of the 4th latent
|
| 42 |
+
forward -- the point where the same counter and the same logits are
|
| 43 |
+
first both available in vLLM. The exit anchor is then injected into
|
| 44 |
+
the sequence so the next step feeds ``</latent>`` as a real token:
|
| 45 |
+
``<latent>``, 4 latent forwards, ``</latent>``.
|
| 46 |
+
|
| 47 |
+
The returned :class:`StepAction` tells the caller what to do with the
|
| 48 |
+
current logits row and with the request's fork fields.
|
| 49 |
+
"""
|
| 50 |
+
from __future__ import annotations
|
| 51 |
+
|
| 52 |
+
from dataclasses import dataclass
|
| 53 |
+
from typing import Callable
|
| 54 |
+
|
| 55 |
+
DECODE, LATENT, FEED, IDLE, DONE = "DECODE", "LATENT", "FEED", "IDLE", "DONE"
|
| 56 |
+
|
| 57 |
+
# HybridAgent trajectory format (reference hybrid_agent/constants.py).
|
| 58 |
+
ENTRY_ANCHOR = "<latent>"
|
| 59 |
+
EXIT_ANCHOR = "</latent>"
|
| 60 |
+
THINK_OPEN = "<think>"
|
| 61 |
+
THINK_CLOSE = "</think>"
|
| 62 |
+
RESULT_OPEN = "<result>"
|
| 63 |
+
TOOL_TAGS = ("search", "python")
|
| 64 |
+
TOOL_STOP_TAGS = tuple(f"</{tag}>" for tag in TOOL_TAGS)
|
| 65 |
+
|
| 66 |
+
MIN_LATENT_STEPS = 4
|
| 67 |
+
MAX_LATENT_STEPS = 200
|
| 68 |
+
TOOL_TAG_TAIL_TOKENS = 50
|
| 69 |
+
DEFAULT_MAX_TURNS = 30
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class StepAction:
|
| 74 |
+
"""What the caller must do with the current step.
|
| 75 |
+
|
| 76 |
+
Attributes:
|
| 77 |
+
force_id: mask the current logits row to this single token
|
| 78 |
+
(FEED observation tokens; latent-exit fallback when no
|
| 79 |
+
sequence injector is available).
|
| 80 |
+
pending_latent: value to write to ``request.pending_latent``
|
| 81 |
+
for the next scheduler pass (fork zero-token step gate).
|
| 82 |
+
inject_token: token id to append to the sequence *now* (the
|
| 83 |
+
exit anchor at a latent exit; in-process engines only).
|
| 84 |
+
blocked_ids: T4b -- token ids to mask to ``-inf`` in the
|
| 85 |
+
current logits row: the reference ``blocked_token_sets``
|
| 86 |
+
text-mode masks (``engine.py:140-149``). Set on DECODE
|
| 87 |
+
steps only; ``None`` elsewhere (LATENT exit readouts stay
|
| 88 |
+
raw, FEED/IDLE/DONE rows are forced/padded).
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
force_id: int | None = None
|
| 92 |
+
pending_latent: bool = False
|
| 93 |
+
inject_token: int | None = None
|
| 94 |
+
blocked_ids: frozenset[int] | None = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class RequestState:
|
| 98 |
+
"""Per-request state of one HybridAgent trajectory row."""
|
| 99 |
+
|
| 100 |
+
def __init__(
|
| 101 |
+
self,
|
| 102 |
+
max_turns: int = DEFAULT_MAX_TURNS,
|
| 103 |
+
min_latent_steps: int = MIN_LATENT_STEPS,
|
| 104 |
+
max_latent_steps: int = MAX_LATENT_STEPS,
|
| 105 |
+
):
|
| 106 |
+
self.mode: str = DECODE
|
| 107 |
+
# T4b: inside the <think>...</think> region? (reference
|
| 108 |
+
# ``RowState.in_think``; initialized from the prompt by the
|
| 109 |
+
# caller, then tracked from the <think>/</think> tokens in the
|
| 110 |
+
# generated sequence -- engine.py:221/239/493-496).
|
| 111 |
+
self.in_think: bool = False
|
| 112 |
+
self.max_turns = int(max_turns)
|
| 113 |
+
self.min_latent_steps = int(min_latent_steps)
|
| 114 |
+
self.max_latent_steps = int(max_latent_steps)
|
| 115 |
+
# steps of the open latent span (completed latent forwards)
|
| 116 |
+
self.latent_steps: int = 0
|
| 117 |
+
self.turn_count: int = 1
|
| 118 |
+
self.feed_queue: list[int] = []
|
| 119 |
+
self.pending_tool: tuple[str, str] | None = None
|
| 120 |
+
self.python_calls: int = 0
|
| 121 |
+
self.search_calls: int = 0
|
| 122 |
+
self.tool_requests: int = 0
|
| 123 |
+
self.anchor_exits: int = 0
|
| 124 |
+
self.forced_exits: int = 0
|
| 125 |
+
self.end_reason: str = ""
|
| 126 |
+
# has left plain DECODE; gates the pending_latent writes so
|
| 127 |
+
# ordinary requests are a strict no-op
|
| 128 |
+
self.dirty: bool = False
|
| 129 |
+
# the <latent> token id captured at entry; a real zero-token
|
| 130 |
+
# step is one whose last sampled token is unchanged
|
| 131 |
+
self._entry_token: int | None = None
|
| 132 |
+
# text tokens since the last resume (tool-tag window source)
|
| 133 |
+
self.text_ids_since_resume: list[int] = []
|
| 134 |
+
|
| 135 |
+
@property
|
| 136 |
+
def is_done(self) -> bool:
|
| 137 |
+
return self.mode == DONE
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def find_tool_tag(tail_text: str) -> str | None:
|
| 141 |
+
"""First closing tool tag in the decoded tail (reference order)."""
|
| 142 |
+
for stop in TOOL_STOP_TAGS:
|
| 143 |
+
if stop in tail_text:
|
| 144 |
+
return stop
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def extract_content(text: str, tag: str) -> str:
|
| 149 |
+
"""Extract text between the *last* ``<tag>...</tag>`` pair
|
| 150 |
+
(reference ``ToolExecutor.extract_content``)."""
|
| 151 |
+
start_tag = f"<{tag}>"
|
| 152 |
+
end_tag = f"</{tag}>"
|
| 153 |
+
try:
|
| 154 |
+
end_pos = text.rindex(end_tag)
|
| 155 |
+
start_pos = text.rindex(start_tag, 0, end_pos)
|
| 156 |
+
return text[start_pos + len(start_tag) : end_pos].strip()
|
| 157 |
+
except ValueError:
|
| 158 |
+
return ""
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _finish(state: RequestState, reason: str) -> None:
|
| 162 |
+
state.mode = DONE
|
| 163 |
+
state.end_reason = state.end_reason or reason
|
| 164 |
+
state.dirty = True
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _exit_latent(state: RequestState, action: StepAction, exit_id: int,
|
| 168 |
+
forced: bool) -> None:
|
| 169 |
+
state.mode = DECODE
|
| 170 |
+
state.latent_steps = 0
|
| 171 |
+
state.dirty = True
|
| 172 |
+
if forced:
|
| 173 |
+
state.forced_exits += 1
|
| 174 |
+
else:
|
| 175 |
+
state.anchor_exits += 1
|
| 176 |
+
action.pending_latent = False
|
| 177 |
+
action.inject_token = exit_id
|
| 178 |
+
# Fallback for engines without an injector (multi-process): mask the
|
| 179 |
+
# current row to the exit anchor. The fork's latent-row sample filter
|
| 180 |
+
# drops that sample, so this path is best-effort; the in-process
|
| 181 |
+
# inject_token path is the parity-correct one.
|
| 182 |
+
action.force_id = exit_id
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _advance_latent(
|
| 186 |
+
state: RequestState,
|
| 187 |
+
action: StepAction,
|
| 188 |
+
last_token: int | None,
|
| 189 |
+
argmax_id: int,
|
| 190 |
+
exit_id: int,
|
| 191 |
+
) -> None:
|
| 192 |
+
if last_token != state._entry_token:
|
| 193 |
+
# Not a zero-token step: the fork scheduled a normal token step
|
| 194 |
+
# (the T2 entry bridge is absent, so the entry step's sample was
|
| 195 |
+
# appended). Do not count it as a latent forward and do not exit;
|
| 196 |
+
# keep the loop pending and decode undisturbed.
|
| 197 |
+
action.pending_latent = True
|
| 198 |
+
return
|
| 199 |
+
# A latent forward just completed (the fork drops its sample).
|
| 200 |
+
state.latent_steps += 1
|
| 201 |
+
# Reference exit order: forced cap first, then the anchor readout.
|
| 202 |
+
if (
|
| 203 |
+
state.max_latent_steps > 0
|
| 204 |
+
and state.latent_steps >= state.max_latent_steps
|
| 205 |
+
):
|
| 206 |
+
_exit_latent(state, action, exit_id, forced=True)
|
| 207 |
+
return
|
| 208 |
+
if (
|
| 209 |
+
state.latent_steps >= state.min_latent_steps
|
| 210 |
+
and argmax_id == exit_id
|
| 211 |
+
):
|
| 212 |
+
_exit_latent(state, action, exit_id, forced=False)
|
| 213 |
+
return
|
| 214 |
+
action.pending_latent = True
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _blocked_token_set(
|
| 218 |
+
state: RequestState,
|
| 219 |
+
entry_id: int,
|
| 220 |
+
exit_id: int,
|
| 221 |
+
think_open_id: int | None,
|
| 222 |
+
think_close_id: int | None,
|
| 223 |
+
) -> frozenset[int]:
|
| 224 |
+
"""T4b: the reference ``blocked_token_sets`` (engine.py:140-145)
|
| 225 |
+
for the current think state, with the think guards active when the
|
| 226 |
+
think-tag ids are known (the reference default ``think_guards``):
|
| 227 |
+
|
| 228 |
+
* in-think -> block ``</latent>`` (and ``<think>``)
|
| 229 |
+
* out-think -> block ``</latent>``, ``<latent>`` (and ``</think>``)
|
| 230 |
+
|
| 231 |
+
so ``</latent>`` can only come from the forced latent-exit readout
|
| 232 |
+
and ``<latent>`` only inside ``<think>``.
|
| 233 |
+
"""
|
| 234 |
+
if state.in_think:
|
| 235 |
+
ids = {exit_id}
|
| 236 |
+
if think_open_id is not None:
|
| 237 |
+
ids.add(think_open_id)
|
| 238 |
+
else:
|
| 239 |
+
ids = {exit_id, entry_id}
|
| 240 |
+
if think_close_id is not None:
|
| 241 |
+
ids.add(think_close_id)
|
| 242 |
+
return frozenset(ids)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _advance_feed(state: RequestState, action: StepAction) -> None:
|
| 246 |
+
# One forced observation token per step (reference FEED).
|
| 247 |
+
tok = state.feed_queue[0]
|
| 248 |
+
state.feed_queue.pop(0)
|
| 249 |
+
action.force_id = tok
|
| 250 |
+
state.dirty = True
|
| 251 |
+
if not state.feed_queue:
|
| 252 |
+
# Observation fully fed: the reference starts the next turn here.
|
| 253 |
+
state.text_ids_since_resume = []
|
| 254 |
+
state.in_think = False # T4b (reference engine.py:434)
|
| 255 |
+
if state.turn_count > state.max_turns:
|
| 256 |
+
_finish(state, "max_turns")
|
| 257 |
+
else:
|
| 258 |
+
state.mode = DECODE
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _advance_decode(
|
| 262 |
+
state: RequestState,
|
| 263 |
+
action: StepAction,
|
| 264 |
+
last_token: int | None,
|
| 265 |
+
tail_text: str,
|
| 266 |
+
full_text_getter: Callable[[], str] | None,
|
| 267 |
+
entry_id: int,
|
| 268 |
+
exit_id: int,
|
| 269 |
+
stop_ids: frozenset[int],
|
| 270 |
+
max_tokens: int | None,
|
| 271 |
+
output_len: int,
|
| 272 |
+
think_open_id: int | None = None,
|
| 273 |
+
think_close_id: int | None = None,
|
| 274 |
+
) -> None:
|
| 275 |
+
# Budget gate (vLLM enforces max_tokens natively too; this keeps the
|
| 276 |
+
# mode bookkeeping consistent).
|
| 277 |
+
if max_tokens is not None and output_len >= max_tokens:
|
| 278 |
+
_finish(state, "budget")
|
| 279 |
+
return
|
| 280 |
+
if last_token is None:
|
| 281 |
+
# T4b: first text step after prefill -- the reference masks it
|
| 282 |
+
# from the start (engine.py:443-451) with the prompt-initialized
|
| 283 |
+
# in_think state.
|
| 284 |
+
action.blocked_ids = _blocked_token_set(
|
| 285 |
+
state, entry_id, exit_id, think_open_id, think_close_id
|
| 286 |
+
)
|
| 287 |
+
return
|
| 288 |
+
if last_token == entry_id:
|
| 289 |
+
# DECODE -> LATENT: <latent> is a real token in the sequence;
|
| 290 |
+
# the current step is the entry (latent) step, whose readout
|
| 291 |
+
# stays raw (reference engine.py:373/446).
|
| 292 |
+
state.mode = LATENT
|
| 293 |
+
state.latent_steps = 0
|
| 294 |
+
state._entry_token = entry_id
|
| 295 |
+
state.dirty = True
|
| 296 |
+
action.pending_latent = True
|
| 297 |
+
return
|
| 298 |
+
if last_token in stop_ids:
|
| 299 |
+
_finish(state, "eos")
|
| 300 |
+
return
|
| 301 |
+
# T4b: think-region tracking from the generated sequence
|
| 302 |
+
# (reference engine.py:493-496).
|
| 303 |
+
if think_open_id is not None and last_token == think_open_id:
|
| 304 |
+
state.in_think = True
|
| 305 |
+
elif think_close_id is not None and last_token == think_close_id:
|
| 306 |
+
state.in_think = False
|
| 307 |
+
# T4b: reference text-mode anchor masks on EVERY text step
|
| 308 |
+
# (engine.py:443-451): </latent> always, <latent> outside <think> --
|
| 309 |
+
# so the anchors are only produced by the state machine's forced
|
| 310 |
+
# tokens.
|
| 311 |
+
action.blocked_ids = _blocked_token_set(
|
| 312 |
+
state, entry_id, exit_id, think_open_id, think_close_id
|
| 313 |
+
)
|
| 314 |
+
if last_token != exit_id:
|
| 315 |
+
# The exit anchor is fed, not model text; keep it out of the
|
| 316 |
+
# tool-tag window.
|
| 317 |
+
state.text_ids_since_resume.append(last_token)
|
| 318 |
+
# Fabricated-observation guard (reference reject_fabricated_result).
|
| 319 |
+
if RESULT_OPEN in tail_text:
|
| 320 |
+
_finish(state, "invalid:fabricated_result")
|
| 321 |
+
return
|
| 322 |
+
stop = find_tool_tag(tail_text)
|
| 323 |
+
if stop is not None:
|
| 324 |
+
tag = stop[2:-1]
|
| 325 |
+
full_text = (
|
| 326 |
+
full_text_getter() if full_text_getter is not None else tail_text
|
| 327 |
+
)
|
| 328 |
+
state.pending_tool = (tag, extract_content(full_text, tag))
|
| 329 |
+
state.tool_requests += 1
|
| 330 |
+
state.mode = IDLE
|
| 331 |
+
state.dirty = True
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def advance(
|
| 335 |
+
state: RequestState,
|
| 336 |
+
*,
|
| 337 |
+
last_token: int | None,
|
| 338 |
+
argmax_id: int,
|
| 339 |
+
tail_text: str = "",
|
| 340 |
+
full_text_getter: Callable[[], str] | None = None,
|
| 341 |
+
entry_id: int,
|
| 342 |
+
exit_id: int,
|
| 343 |
+
stop_ids: frozenset[int] = frozenset(),
|
| 344 |
+
max_tokens: int | None = None,
|
| 345 |
+
output_len: int = 0,
|
| 346 |
+
think_open_id: int | None = None,
|
| 347 |
+
think_close_id: int | None = None,
|
| 348 |
+
) -> StepAction:
|
| 349 |
+
"""Advance one request's state machine by one generation step."""
|
| 350 |
+
action = StepAction()
|
| 351 |
+
if state.mode == DONE:
|
| 352 |
+
return action
|
| 353 |
+
if state.mode == LATENT:
|
| 354 |
+
_advance_latent(state, action, last_token, argmax_id, exit_id)
|
| 355 |
+
return action
|
| 356 |
+
if state.mode == FEED:
|
| 357 |
+
_advance_feed(state, action)
|
| 358 |
+
return action
|
| 359 |
+
if state.mode == IDLE:
|
| 360 |
+
# Paused at the tool barrier; T5 resumes via queue_observation().
|
| 361 |
+
return action
|
| 362 |
+
_advance_decode(
|
| 363 |
+
state,
|
| 364 |
+
action,
|
| 365 |
+
last_token,
|
| 366 |
+
tail_text,
|
| 367 |
+
full_text_getter,
|
| 368 |
+
entry_id,
|
| 369 |
+
exit_id,
|
| 370 |
+
stop_ids,
|
| 371 |
+
max_tokens,
|
| 372 |
+
output_len,
|
| 373 |
+
think_open_id,
|
| 374 |
+
think_close_id,
|
| 375 |
+
)
|
| 376 |
+
return action
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def queue_observation(state: RequestState, token_ids: list[int]) -> None:
|
| 380 |
+
"""T5 tool barrier: resume an IDLE request with an observation
|
| 381 |
+
(``<result>...`` token ids). Mirrors the reference
|
| 382 |
+
``_tool_barrier``: the turn is counted; a non-empty observation
|
| 383 |
+
enters FEED (one forced token per step); an empty one goes straight
|
| 384 |
+
back to DECODE (or DONE past max_turns)."""
|
| 385 |
+
if state.mode != IDLE:
|
| 386 |
+
raise ValueError(
|
| 387 |
+
f"queue_observation in mode {state.mode} (expected IDLE)"
|
| 388 |
+
)
|
| 389 |
+
state.pending_tool = None
|
| 390 |
+
state.turn_count += 1
|
| 391 |
+
state.dirty = True
|
| 392 |
+
if token_ids:
|
| 393 |
+
state.feed_queue = list(token_ids)
|
| 394 |
+
state.mode = FEED
|
| 395 |
+
else:
|
| 396 |
+
state.text_ids_since_resume = []
|
| 397 |
+
state.in_think = False # T4b (reference engine.py:590)
|
| 398 |
+
if state.turn_count > state.max_turns:
|
| 399 |
+
_finish(state, "max_turns")
|
| 400 |
+
else:
|
| 401 |
+
state.mode = DECODE
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def record_tool_call(state: RequestState, tag: str) -> None:
|
| 405 |
+
"""T5: report an executed tool call (per-tag counter bookkeeping;
|
| 406 |
+
cap enforcement is T5's)."""
|
| 407 |
+
if tag == "python":
|
| 408 |
+
state.python_calls += 1
|
| 409 |
+
elif tag == "search":
|
| 410 |
+
state.search_calls += 1
|
serving/processor/test_blocked_masks.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/opt/hybridagent-vllm/venv/bin/python
|
| 2 |
+
"""T4b -- tests for the reference ``blocked_token_sets`` port.
|
| 3 |
+
|
| 4 |
+
Validates the token-blocking masks ported from
|
| 5 |
+
togethercomputer/HybridAgent's ``HybridGenerationEngine``
|
| 6 |
+
(``hybrid_agent/engine.py:140-149`` sets, ``:221/:239`` prompt
|
| 7 |
+
initialization, ``:443-451`` per-step application, ``:493-496``
|
| 8 |
+
think tracking, ``:434/:590`` FEED reset; ``sampling.py:75``
|
| 9 |
+
masked_fill) into the T4 state machine + vLLM logits processor:
|
| 10 |
+
|
| 11 |
+
in_think blocks {</latent>, <think>}
|
| 12 |
+
out_think blocks {</latent>, <latent>, </think>}
|
| 13 |
+
|
| 14 |
+
applied to every DECODE (text) logits row, from the very first step,
|
| 15 |
+
so ``</latent>`` can only come from the forced latent-exit readout
|
| 16 |
+
and ``<latent>`` only inside ``<think>``. LATENT exit readouts stay
|
| 17 |
+
raw; FEED/exit forcing is untouched.
|
| 18 |
+
|
| 19 |
+
Run:
|
| 20 |
+
/opt/hybridagent-vllm/venv/bin/python \
|
| 21 |
+
/opt/hybridagent-vllm/processor/test_blocked_masks.py
|
| 22 |
+
|
| 23 |
+
Exit code 0 = PASS, 1 = FAIL.
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
# This file lives inside the processor/ package dir; drop that dir from
|
| 31 |
+
# sys.path (the script dir) so `import processor` resolves to the
|
| 32 |
+
# package itself, not processor/processor.py.
|
| 33 |
+
_HERE = Path(__file__).resolve().parent
|
| 34 |
+
sys.path = [p for p in sys.path if not p or Path(p).resolve() != _HERE]
|
| 35 |
+
sys.path.insert(0, str(_HERE.parent))
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
|
| 39 |
+
import processor
|
| 40 |
+
import processor.processor as pp
|
| 41 |
+
from processor import HybridAgentLogitsProcessor
|
| 42 |
+
from processor.state_machine import (
|
| 43 |
+
DECODE,
|
| 44 |
+
DONE,
|
| 45 |
+
FEED,
|
| 46 |
+
IDLE,
|
| 47 |
+
LATENT,
|
| 48 |
+
RequestState,
|
| 49 |
+
advance,
|
| 50 |
+
queue_observation,
|
| 51 |
+
)
|
| 52 |
+
from vllm.sampling_params import SamplingParams
|
| 53 |
+
from vllm.v1.sample.logits_processor import BatchUpdate
|
| 54 |
+
|
| 55 |
+
# Token plan (V=64): 10=<latent>, 11=</latent>, 12=<think>, 13=</think>,
|
| 56 |
+
# 14=eos, 20/21=observation tokens, 40/41/30=<python>/print(1)//</python>.
|
| 57 |
+
V = 64
|
| 58 |
+
L, R, T, TC, EOS = 10, 11, 12, 13, 14
|
| 59 |
+
PLAIN, B2 = 5, 6
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class StubTok:
|
| 63 |
+
unk_token_id = None
|
| 64 |
+
eos_token_id = EOS
|
| 65 |
+
SPECIAL = {
|
| 66 |
+
"<latent>": L,
|
| 67 |
+
"</latent>": R,
|
| 68 |
+
"<think>": T,
|
| 69 |
+
"</think>": TC,
|
| 70 |
+
"<result>": 20,
|
| 71 |
+
"</result>": 22,
|
| 72 |
+
"<python>": 40,
|
| 73 |
+
"</python>": 30,
|
| 74 |
+
"<search>": 43,
|
| 75 |
+
"</search>": 31,
|
| 76 |
+
}
|
| 77 |
+
TEXT = {5: "hello", 6: " world", 41: "print(1)"}
|
| 78 |
+
|
| 79 |
+
def __init__(self):
|
| 80 |
+
self.id2str = {**self.TEXT, **{v: k for k, v in self.SPECIAL.items()}}
|
| 81 |
+
|
| 82 |
+
def convert_tokens_to_ids(self, token):
|
| 83 |
+
return self.SPECIAL.get(token)
|
| 84 |
+
|
| 85 |
+
def decode(self, ids, skip_special_tokens=False):
|
| 86 |
+
return "".join(self.id2str.get(i, f" t{i} ") for i in ids)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class FakeRequest:
|
| 90 |
+
def __init__(self):
|
| 91 |
+
self.pending_latent = False
|
| 92 |
+
self.engine_outputs: list[int] = []
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class FakeEngine:
|
| 96 |
+
def __init__(self, tokenizer):
|
| 97 |
+
self.tokenizer = tokenizer
|
| 98 |
+
self.requests = {"reqA": FakeRequest()}
|
| 99 |
+
self.worker_lists: dict[str, list[int]] = {"reqA": []}
|
| 100 |
+
self.inject_log: list[tuple[str, int]] = []
|
| 101 |
+
|
| 102 |
+
def get_request(self, rid):
|
| 103 |
+
return self.requests.get(rid)
|
| 104 |
+
|
| 105 |
+
def get_req_ids(self):
|
| 106 |
+
return ["reqA"]
|
| 107 |
+
|
| 108 |
+
def append(self, rid, tok):
|
| 109 |
+
self.requests[rid].engine_outputs.append(tok)
|
| 110 |
+
self.worker_lists[rid].append(tok)
|
| 111 |
+
|
| 112 |
+
def inject_token(self, rid, tok):
|
| 113 |
+
self.append(rid, tok)
|
| 114 |
+
self.inject_log.append((rid, tok))
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def make_proc(prompt_ids: list[int]):
|
| 118 |
+
pp._engine = None
|
| 119 |
+
pp._PROCESSORS.clear()
|
| 120 |
+
tok = StubTok()
|
| 121 |
+
eng = FakeEngine(tok)
|
| 122 |
+
pp.register_engine(
|
| 123 |
+
get_request=eng.get_request,
|
| 124 |
+
get_req_ids=eng.get_req_ids,
|
| 125 |
+
tokenizer=tok,
|
| 126 |
+
inject_token=eng.inject_token,
|
| 127 |
+
)
|
| 128 |
+
proc = HybridAgentLogitsProcessor(
|
| 129 |
+
vllm_config=None, device=torch.device("cpu"), is_pin_memory=False
|
| 130 |
+
)
|
| 131 |
+
params = SamplingParams(max_tokens=64, temperature=0.0)
|
| 132 |
+
out: list[int] = []
|
| 133 |
+
eng.worker_lists["reqA"] = out
|
| 134 |
+
proc.update_state(
|
| 135 |
+
BatchUpdate(
|
| 136 |
+
batch_size=1,
|
| 137 |
+
removed=(),
|
| 138 |
+
added=((0, params, list(prompt_ids), out),),
|
| 139 |
+
moved=(),
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
return proc, eng, tok
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def sm(state: RequestState, **kw):
|
| 146 |
+
defaults = dict(
|
| 147 |
+
last_token=None,
|
| 148 |
+
argmax_id=PLAIN,
|
| 149 |
+
tail_text="",
|
| 150 |
+
full_text_getter=None,
|
| 151 |
+
entry_id=L,
|
| 152 |
+
exit_id=R,
|
| 153 |
+
stop_ids=frozenset({EOS}),
|
| 154 |
+
max_tokens=None,
|
| 155 |
+
output_len=0,
|
| 156 |
+
think_open_id=T,
|
| 157 |
+
think_close_id=TC,
|
| 158 |
+
)
|
| 159 |
+
defaults.update(kw)
|
| 160 |
+
return advance(state, **defaults)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
failures: list[str] = []
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def check(name, cond, detail=""):
|
| 167 |
+
status = "ok" if cond else "FAIL"
|
| 168 |
+
print(f" [{name}] {status}" + (f" -- {detail}" if detail and not cond else ""))
|
| 169 |
+
if not cond:
|
| 170 |
+
failures.append(f"{name}: {detail}")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def main() -> int:
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
print("== SM1: in-think blocked set (engine.py:140-144) ==")
|
| 176 |
+
st = RequestState()
|
| 177 |
+
st.in_think = True
|
| 178 |
+
a = sm(st)
|
| 179 |
+
check("in-think blocks {</latent>, <think>}",
|
| 180 |
+
a.blocked_ids == frozenset({R, T}), str(a.blocked_ids))
|
| 181 |
+
|
| 182 |
+
print("== SM2: out-of-think blocked set (engine.py:140-144) ==")
|
| 183 |
+
st = RequestState()
|
| 184 |
+
st.in_think = False
|
| 185 |
+
a = sm(st)
|
| 186 |
+
check("out-of-think blocks {</latent>, <latent>, </think>}",
|
| 187 |
+
a.blocked_ids == frozenset({R, L, TC}), str(a.blocked_ids))
|
| 188 |
+
|
| 189 |
+
print("== SM3: mask active from the very first text step ==")
|
| 190 |
+
st = RequestState()
|
| 191 |
+
a = sm(st) # last_token=None
|
| 192 |
+
check("first step carries a blocked set",
|
| 193 |
+
a.blocked_ids is not None, str(a.blocked_ids))
|
| 194 |
+
|
| 195 |
+
print("== SM4: in_think tracked from <think>/</think> tokens ==")
|
| 196 |
+
st = RequestState()
|
| 197 |
+
sm(st, last_token=T)
|
| 198 |
+
check("sampling <think> sets in_think", st.in_think is True, str(st.in_think))
|
| 199 |
+
sm(st, last_token=TC)
|
| 200 |
+
check("sampling </think> clears in_think", st.in_think is False,
|
| 201 |
+
str(st.in_think))
|
| 202 |
+
|
| 203 |
+
print("== SM5: FEED drain resets in_think (engine.py:434) ==")
|
| 204 |
+
st = RequestState()
|
| 205 |
+
st.mode = FEED
|
| 206 |
+
st.feed_queue = [20, 21]
|
| 207 |
+
st.in_think = True
|
| 208 |
+
a = sm(st)
|
| 209 |
+
check("feed step 1 forces token 20", a.force_id == 20, str(a.force_id))
|
| 210 |
+
check("feed step 1 has no text mask", a.blocked_ids is None,
|
| 211 |
+
str(a.blocked_ids))
|
| 212 |
+
a = sm(st)
|
| 213 |
+
check("feed drain forces last token", a.force_id == 21, str(a.force_id))
|
| 214 |
+
check("drain returns to DECODE", st.mode == DECODE, st.mode)
|
| 215 |
+
check("drain resets in_think", st.in_think is False, str(st.in_think))
|
| 216 |
+
|
| 217 |
+
print("== SM6: empty observation resets in_think (engine.py:590) ==")
|
| 218 |
+
st = RequestState()
|
| 219 |
+
st.mode = IDLE
|
| 220 |
+
st.in_think = True
|
| 221 |
+
queue_observation(st, [])
|
| 222 |
+
check("empty observation -> DECODE", st.mode == DECODE, st.mode)
|
| 223 |
+
check("in_think reset", st.in_think is False, str(st.in_think))
|
| 224 |
+
|
| 225 |
+
print("== SM7: LATENT exit readout stays raw; forcing intact ==")
|
| 226 |
+
st = RequestState()
|
| 227 |
+
st.mode = LATENT
|
| 228 |
+
st._entry_token = L
|
| 229 |
+
a = sm(st, last_token=L, argmax_id=PLAIN)
|
| 230 |
+
check("latent step has no text mask", a.blocked_ids is None,
|
| 231 |
+
str(a.blocked_ids))
|
| 232 |
+
check("latent step keeps pending_latent", a.pending_latent is True,
|
| 233 |
+
str(a.pending_latent))
|
| 234 |
+
for _ in range(3):
|
| 235 |
+
st.latent_steps += 1
|
| 236 |
+
a = sm(st, last_token=L, argmax_id=R)
|
| 237 |
+
check("anchor exit still forced at min steps",
|
| 238 |
+
a.force_id == R and a.inject_token == R, str(a))
|
| 239 |
+
check("exit clears pending_latent", a.pending_latent is False,
|
| 240 |
+
str(a.pending_latent))
|
| 241 |
+
check("exit back in DECODE", st.mode == DECODE, st.mode)
|
| 242 |
+
|
| 243 |
+
print("== SM8: IDLE/DONE carry no mask ==")
|
| 244 |
+
st = RequestState()
|
| 245 |
+
st.mode = IDLE
|
| 246 |
+
check("IDLE no mask", sm(st).blocked_ids is None)
|
| 247 |
+
st = RequestState()
|
| 248 |
+
st.mode = DONE
|
| 249 |
+
check("DONE no mask", sm(st).blocked_ids is None)
|
| 250 |
+
|
| 251 |
+
print("== SM9: unknown think tokens -> anchor-only sets ==")
|
| 252 |
+
st = RequestState()
|
| 253 |
+
st.in_think = True
|
| 254 |
+
a = sm(st, think_open_id=None, think_close_id=None)
|
| 255 |
+
check("in-think without guards = {</latent>}",
|
| 256 |
+
a.blocked_ids == frozenset({R}), str(a.blocked_ids))
|
| 257 |
+
st.in_think = False
|
| 258 |
+
a = sm(st, think_open_id=None, think_close_id=None)
|
| 259 |
+
check("out-think without guards = {</latent>, <latent>}",
|
| 260 |
+
a.blocked_ids == frozenset({R, L}), str(a.blocked_ids))
|
| 261 |
+
|
| 262 |
+
# ------------------------------------------------------------------
|
| 263 |
+
print("== P1: DECODE row masked at the blocked ids only ==")
|
| 264 |
+
proc, eng, tok = make_proc([T]) # prompt ends in <think>
|
| 265 |
+
st = processor.get_state("reqA")
|
| 266 |
+
check("in_think initialized from prompt", st.in_think is True,
|
| 267 |
+
str(st.in_think))
|
| 268 |
+
logits = torch.full((1, V), -10.0, dtype=torch.float32)
|
| 269 |
+
logits[0, R] = 0.0 # greedy argmax is the exit anchor
|
| 270 |
+
logits[0, B2] = -5.0
|
| 271 |
+
proc.apply(logits)
|
| 272 |
+
check("</latent> masked to -inf",
|
| 273 |
+
torch.isinf(logits[0, R]) and logits[0, R] < 0, str(logits[0, R]))
|
| 274 |
+
check("<think> masked to -inf (in-think guard)",
|
| 275 |
+
torch.isinf(logits[0, T]) and logits[0, T] < 0, str(logits[0, T]))
|
| 276 |
+
check("<latent> NOT blocked in-think",
|
| 277 |
+
logits[0, L] == -10.0, str(logits[0, L]))
|
| 278 |
+
check("other logits untouched",
|
| 279 |
+
logits[0, PLAIN] == -10.0 and logits[0, B2] == -5.0,
|
| 280 |
+
str(logits[0, [PLAIN, B2]]))
|
| 281 |
+
check("greedy argmax flips away from </latent>",
|
| 282 |
+
int(logits[0].argmax()) == B2, str(int(logits[0].argmax())))
|
| 283 |
+
|
| 284 |
+
print("== P2: out-of-think row blocks <latent> ==")
|
| 285 |
+
proc, eng, tok = make_proc([5]) # prompt does not end in <think>
|
| 286 |
+
st = processor.get_state("reqA")
|
| 287 |
+
check("in_think False for plain prompt", st.in_think is False,
|
| 288 |
+
str(st.in_think))
|
| 289 |
+
logits = torch.full((1, V), -10.0, dtype=torch.float32)
|
| 290 |
+
logits[0, L] = 0.0 # greedy argmax is the entry anchor
|
| 291 |
+
logits[0, B2] = -5.0
|
| 292 |
+
proc.apply(logits)
|
| 293 |
+
check("<latent> masked to -inf",
|
| 294 |
+
torch.isinf(logits[0, L]) and logits[0, L] < 0, str(logits[0, L]))
|
| 295 |
+
check("</think> masked to -inf (out-of-think guard)",
|
| 296 |
+
torch.isinf(logits[0, TC]) and logits[0, TC] < 0,
|
| 297 |
+
str(logits[0, TC]))
|
| 298 |
+
check("<think> NOT blocked out-of-think",
|
| 299 |
+
logits[0, T] == -10.0, str(logits[0, T]))
|
| 300 |
+
check("greedy argmax flips away from <latent>",
|
| 301 |
+
int(logits[0].argmax()) == B2, str(int(logits[0].argmax())))
|
| 302 |
+
|
| 303 |
+
print("== P3: FEED forcing is not weakened by the masks ==")
|
| 304 |
+
proc, eng, tok = make_proc([T])
|
| 305 |
+
# The tool tag fires on the apply() step AFTER </python> is sampled.
|
| 306 |
+
script = [40, 41, 30, PLAIN]
|
| 307 |
+
for arg in script:
|
| 308 |
+
logits = torch.full((1, V), -10.0, dtype=torch.float32)
|
| 309 |
+
logits[0, arg] = 0.0
|
| 310 |
+
proc.apply(logits)
|
| 311 |
+
eng.append("reqA", int(logits[0].argmax()))
|
| 312 |
+
st = processor.get_state("reqA")
|
| 313 |
+
check("tool tag -> IDLE with pending_tool",
|
| 314 |
+
st.mode == IDLE and st.pending_tool == ("python", "print(1)"),
|
| 315 |
+
f"{st.mode} {st.pending_tool}")
|
| 316 |
+
processor.queue_observation("reqA", [20, 21])
|
| 317 |
+
st = processor.get_state("reqA")
|
| 318 |
+
check("IDLE -> FEED", st.mode == FEED, st.mode)
|
| 319 |
+
logits = torch.full((1, V), -10.0, dtype=torch.float32)
|
| 320 |
+
proc.apply(logits)
|
| 321 |
+
n_inf = int(torch.isneginf(logits[0]).sum())
|
| 322 |
+
check("feed row forced to token 20",
|
| 323 |
+
logits[0, 20] == 0.0 and n_inf == V - 1,
|
| 324 |
+
f"tok20={logits[0, 20]} n_inf={n_inf}")
|
| 325 |
+
|
| 326 |
+
print("== P4: LATENT row logits stay raw (exit readout unmasked) ==")
|
| 327 |
+
proc, eng, tok = make_proc([T])
|
| 328 |
+
logits = torch.full((1, V), -10.0, dtype=torch.float32)
|
| 329 |
+
logits[0, L] = 0.0
|
| 330 |
+
proc.apply(logits)
|
| 331 |
+
eng.append("reqA", int(logits[0].argmax())) # <latent> sampled
|
| 332 |
+
# Next apply: the state machine sees last_token=<latent> -> LATENT.
|
| 333 |
+
before = torch.full((1, V), -10.0, dtype=torch.float32)
|
| 334 |
+
before[0, R] = 0.0
|
| 335 |
+
logits = before.clone()
|
| 336 |
+
proc.apply(logits)
|
| 337 |
+
st = processor.get_state("reqA")
|
| 338 |
+
check("entered LATENT", st.mode == LATENT, st.mode)
|
| 339 |
+
check("latent row byte-identical (no mask, no force)",
|
| 340 |
+
torch.equal(logits, before), str(logits[0]))
|
| 341 |
+
|
| 342 |
+
# ------------------------------------------------------------------
|
| 343 |
+
print()
|
| 344 |
+
if failures:
|
| 345 |
+
print(f"FAIL ({len(failures)} problem(s)):")
|
| 346 |
+
for f in failures:
|
| 347 |
+
print(f" - {f}")
|
| 348 |
+
return 1
|
| 349 |
+
print("PASS: T4b blocked_token_sets masks (state machine + processor)")
|
| 350 |
+
return 0
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
if __name__ == "__main__":
|
| 354 |
+
sys.exit(main())
|
serving/serve.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""T7b — serve the HybridAgent-8B backbone with OUR stack.
|
| 3 |
+
|
| 4 |
+
This is NOT the `vllm serve` CLI. The HybridAgent latent loop needs
|
| 5 |
+
IN-PROCESS engine access (the T4 state machine reaches engine-side
|
| 6 |
+
`Request`s through registered handles, and the T5 orchestrator appends
|
| 7 |
+
tokens via direct request mutation), so the engine runs in this process
|
| 8 |
+
(`VLLM_ENABLE_V1_MULTIPROCESSING=0`) and is driven by the
|
| 9 |
+
`HybridAgentOrchestrator`.
|
| 10 |
+
|
| 11 |
+
T7b-full (this configuration) serves the **EXL3-quantized** backbone
|
| 12 |
+
(T6: 5.0 bpw/H5, `model.safetensors` 5.98 GB) from the staged serving
|
| 13 |
+
dir `serve/exl3-staged/` — symlinks to `models/HybridAgent-8B-EXL3/`
|
| 14 |
+
plus the tokenizer files back-filled from the BF16 dir (stage-5 gap:
|
| 15 |
+
`convert.py` did not retain them). The HybridLink `adapter.pt` loads
|
| 16 |
+
as BF16 next to the checkpoint (S4). The KV budget is 15e9 B
|
| 17 |
+
(~101k tokens ≈ 3 concurrent full-32k sessions). The BF16 smoke
|
| 18 |
+
config (T7b-smoke) is still reachable via env:
|
| 19 |
+
`HYBRIDAGENT_MODEL_DIR=/opt/hybridagent-vllm/models/HybridAgent-8B
|
| 20 |
+
HYBRIDAGENT_KV_BYTES=5000000000`.
|
| 21 |
+
|
| 22 |
+
Required engine semantics (feature doc L6/L8, T2 fork):
|
| 23 |
+
* VLLM_USE_V2_MODEL_RUNNER=0 — the fork modifies the V1 runner
|
| 24 |
+
(`v1/worker/gpu_model_runner.py`); vLLM 0.30 defaults to V2, where the
|
| 25 |
+
latent path would be silently inactive.
|
| 26 |
+
* async_scheduling=False — forced by the orchestrator; the async
|
| 27 |
+
scheduler keeps `num_new_tokens > 0` every step, which blocks the
|
| 28 |
+
fork's zero-token latent gate.
|
| 29 |
+
* enable_prompt_embeds=True — the fork feeds R(h) latent embeddings
|
| 30 |
+
through the mixed-batch prompt-embeds path (is_token_ids=False);
|
| 31 |
+
the T7a parity run booted with it.
|
| 32 |
+
* enforce_eager=True — the proven parity config; avoids CUDA-graph
|
| 33 |
+
memory overhead on the 24 GB 3090.
|
| 34 |
+
|
| 35 |
+
KV sizing (BF16): the guide fixes the KV of the hybrid (GDN) models with
|
| 36 |
+
`--kv-cache-memory-bytes` because GPU-memory profiling underestimates GDN
|
| 37 |
+
layers and OOMs. HybridAgent-8B is a PLAIN full-attention transformer
|
| 38 |
+
(36 × full_attention, no GDN), so utilization-based sizing would be
|
| 39 |
+
acceptable; we keep the explicit `kv_cache_memory_bytes` because it makes
|
| 40 |
+
the VRAM budget deterministic against the "≥100 MiB really free" battery
|
| 41 |
+
criterion. BF16 weights are ~15.7 GB (vs ~5.4 GB EXL3), so the BF16 KV
|
| 42 |
+
budget is 5e9 B (~33.9k tokens ≈ one full 32k session with margin);
|
| 43 |
+
see README.md for the numbers.
|
| 44 |
+
|
| 45 |
+
Usage (on tarninfer):
|
| 46 |
+
cd /opt/hybridagent-vllm
|
| 47 |
+
venv/bin/python serve/serve.py
|
| 48 |
+
then type one prompt per line; a BLANK line flushes (runs all pending
|
| 49 |
+
prompts in one batched loop) and prints their final texts; EOF exits.
|
| 50 |
+
"""
|
| 51 |
+
from __future__ import annotations
|
| 52 |
+
|
| 53 |
+
import os
|
| 54 |
+
import sys
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Environment — MUST be set before vllm is imported.
|
| 58 |
+
# (The orchestrator module also setdefaults the two fork-semantics vars;
|
| 59 |
+
# setting them here keeps this file correct if imported standalone.)
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
os.environ.setdefault("VLLM_USE_V2_MODEL_RUNNER", "0") # fork is V1 (L6)
|
| 62 |
+
os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0") # in-process core
|
| 63 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 64 |
+
os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
|
| 65 |
+
os.environ.setdefault("CUDA_HOME", "/usr/local/cuda-13.2")
|
| 66 |
+
os.environ.setdefault("VLLM_CACHE_ROOT", "/var/tmp/vllm-hybridagent")
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Configuration (T7b-full: EXL3 checkpoint; see README.md)
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
# The serving dir: EXL3 weights + adapter.pt + adapter_config.json +
|
| 72 |
+
# tokenizer files, in ONE directory (the T3 backend loads the adapter from
|
| 73 |
+
# next to the checkpoint). serve/exl3-staged/ is the T6 EXL3 dir
|
| 74 |
+
# (models/HybridAgent-8B-EXL3) with symlinks plus the tokenizer files
|
| 75 |
+
# back-filled from the BF16 dir (the stage-5 "verify tokenizer files"
|
| 76 |
+
# step). Point HYBRIDAGENT_MODEL_DIR at the BF16 dir to re-run the
|
| 77 |
+
# T7b-smoke config.
|
| 78 |
+
MODEL_DIR = os.environ.get(
|
| 79 |
+
"HYBRIDAGENT_MODEL_DIR",
|
| 80 |
+
"/opt/hybridagent-vllm/serve/exl3-staged",
|
| 81 |
+
)
|
| 82 |
+
MAX_MODEL_LEN = 32_768 # the model's native context window
|
| 83 |
+
# EXL3 weights are ~5.98 GB (+0.07 GB BF16 adapter), so the KV budget is
|
| 84 |
+
# 15e9 B / 147,456 B-per-token ≈ 101.7k tokens ≈ 3 concurrent full-32k
|
| 85 |
+
# sessions. The BF16 smoke used 5e9 (≈33.9k tokens) against its 15.7 GB
|
| 86 |
+
# weights.
|
| 87 |
+
KV_CACHE_MEMORY_BYTES = int(
|
| 88 |
+
os.environ.get("HYBRIDAGENT_KV_BYTES", "15_000_000_000")
|
| 89 |
+
.replace("_", "")
|
| 90 |
+
)
|
| 91 |
+
# Total vLLM memory budget (fraction of the 24 GB). EXL3 weights
|
| 92 |
+
# (~5.98 GB) + KV (15e9 B ≈ 14.2 GB) + runtime (~2 GB) ≈ 22.2 GB →
|
| 93 |
+
# 0.93 (≈22.9 GB) leaves ≥ ~100 MiB really free for the battery
|
| 94 |
+
# criterion.
|
| 95 |
+
GPU_MEMORY_UTILIZATION = float(os.environ.get("HYBRIDAGENT_GPU_UTIL", "0.93"))
|
| 96 |
+
MAX_NUM_BATCHED_TOKENS = 1_024 # the guide's serving services use 1024
|
| 97 |
+
DTYPE = "bfloat16"
|
| 98 |
+
ENFORCE_EAGER = True # proven parity config (T7a)
|
| 99 |
+
ENABLE_PROMPT_EMBEDS = True # fork latent-embed mixed-batch path
|
| 100 |
+
MAX_TURNS = 30 # the reference engine's turn cap
|
| 101 |
+
MAX_TOKENS_PER_REQ = 4_096 # per-prompt output budget
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def main() -> int:
|
| 105 |
+
# The two engine-side plugins must be installed in this venv
|
| 106 |
+
# (pip install -e backend, pip install -e processor); an early import
|
| 107 |
+
# fails fast with a clear message instead of mid-boot.
|
| 108 |
+
import backend # noqa: F401 (vllm.general_plugins entry point)
|
| 109 |
+
import processor # noqa: F401 (vllm.logits_processors entry point)
|
| 110 |
+
# The orchestrator package is NOT installed (no entry point); the
|
| 111 |
+
# repo root must be on the path. Add it AFTER backend/processor are
|
| 112 |
+
# imported: with REPO first, `import backend` would resolve to the
|
| 113 |
+
# namespace dir /opt/hybridagent-vllm/backend/ and shadow the
|
| 114 |
+
# installed package (same ordering rule as battery/run_battery.py).
|
| 115 |
+
sys.path.insert(
|
| 116 |
+
0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 117 |
+
from orchestrator import HybridAgentOrchestrator
|
| 118 |
+
|
| 119 |
+
print(
|
| 120 |
+
f"[serve] T7b-full — model={MODEL_DIR}\n"
|
| 121 |
+
f"[serve] max_model_len={MAX_MODEL_LEN} "
|
| 122 |
+
f"kv_cache_memory_bytes={KV_CACHE_MEMORY_BYTES} "
|
| 123 |
+
f"gpu_memory_utilization={GPU_MEMORY_UTILIZATION} "
|
| 124 |
+
f"max_num_batched_tokens={MAX_NUM_BATCHED_TOKENS} dtype={DTYPE}\n"
|
| 125 |
+
f"[serve] VLLM_USE_V2_MODEL_RUNNER=0, async_scheduling=False (forced), "
|
| 126 |
+
f"enforce_eager={ENFORCE_EAGER}, enable_prompt_embeds={ENABLE_PROMPT_EMBEDS}"
|
| 127 |
+
)
|
| 128 |
+
if not os.path.isfile(os.path.join(MODEL_DIR, "adapter.pt")):
|
| 129 |
+
print(
|
| 130 |
+
f"[serve] WARNING: adapter.pt not found in {MODEL_DIR} — "
|
| 131 |
+
"the T3 backend will refuse to load. Fix HYBRIDAGENT_MODEL_DIR."
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
orch = HybridAgentOrchestrator(
|
| 135 |
+
model=MODEL_DIR,
|
| 136 |
+
max_turns=MAX_TURNS,
|
| 137 |
+
dtype=DTYPE,
|
| 138 |
+
enforce_eager=ENFORCE_EAGER,
|
| 139 |
+
enable_prompt_embeds=ENABLE_PROMPT_EMBEDS,
|
| 140 |
+
max_model_len=MAX_MODEL_LEN,
|
| 141 |
+
kv_cache_memory_bytes=KV_CACHE_MEMORY_BYTES,
|
| 142 |
+
gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
|
| 143 |
+
max_num_batched_tokens=MAX_NUM_BATCHED_TOKENS,
|
| 144 |
+
)
|
| 145 |
+
print("[serve] engine up; one prompt per line, blank line = run batch")
|
| 146 |
+
|
| 147 |
+
def flush(prompts: list[str]) -> None:
|
| 148 |
+
if not prompts:
|
| 149 |
+
return
|
| 150 |
+
rids = [
|
| 151 |
+
orch.add_request(p, max_tokens=MAX_TOKENS_PER_REQ) for p in prompts
|
| 152 |
+
]
|
| 153 |
+
orch.run() # drives every submitted request to DONE (batched loop)
|
| 154 |
+
for rid in rids:
|
| 155 |
+
text = orch.final_text(rid)
|
| 156 |
+
print(f"\n[serve] === {rid} ===\n{text}\n", flush=True)
|
| 157 |
+
|
| 158 |
+
pending: list[str] = []
|
| 159 |
+
try:
|
| 160 |
+
for line in sys.stdin:
|
| 161 |
+
line = line.rstrip("\n")
|
| 162 |
+
if not line:
|
| 163 |
+
flush(pending)
|
| 164 |
+
pending = []
|
| 165 |
+
else:
|
| 166 |
+
pending.append(line)
|
| 167 |
+
flush(pending)
|
| 168 |
+
except KeyboardInterrupt:
|
| 169 |
+
print("\n[serve] interrupted; shutting down")
|
| 170 |
+
finally:
|
| 171 |
+
orch.shutdown()
|
| 172 |
+
return 0
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
sys.exit(main())
|
serving/setup.sh
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Deploy the HybridAgent-8B EXL3 model with the forked vLLM stack.
|
| 3 |
+
# Run from the serving/ directory of a fresh clone of this repo:
|
| 4 |
+
# git clone https://huggingface.co/JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental
|
| 5 |
+
# cd hybridagent-8b-exl3-vllm-experimental/serving
|
| 6 |
+
# ./setup.sh
|
| 7 |
+
#
|
| 8 |
+
# Stack: Python 3.12, vLLM 0.30.0, torch 2.13.0, exllamav3 1.5.1,
|
| 9 |
+
# OrcaSAQ2-kernel (EXL3 plugin), the hybridagent backend+processor,
|
| 10 |
+
# and the fork's v1 runner files overlaid onto the installed vLLM.
|
| 11 |
+
set -euo pipefail
|
| 12 |
+
cd "$(dirname "$0")" # serving/
|
| 13 |
+
|
| 14 |
+
PY=${PY:-3.12}
|
| 15 |
+
VENV=${VENV:-../venv}
|
| 16 |
+
ORCASAQ2_COMMIT=${ORCASAQ2_COMMIT:-7ddffb12c00c24b8bc6b1164723c04ac490a91bc}
|
| 17 |
+
|
| 18 |
+
echo "[1/6] venv (python ${PY})"
|
| 19 |
+
python${PY} -m venv "${VENV}"
|
| 20 |
+
PIP="${VENV}/bin/pip"
|
| 21 |
+
"${PIP}" install -U pip
|
| 22 |
+
|
| 23 |
+
echo "[2/6] vLLM 0.30.0 (pulls torch 2.13.0)"
|
| 24 |
+
"${PIP}" install "vllm==0.30.0"
|
| 25 |
+
|
| 26 |
+
echo "[3/6] exllamav3 1.5.1 (cu132, torch 2.13.0, cp312)"
|
| 27 |
+
W="exllamav3-1.5.1+cu132.torch2.13.0-cp312-cp312-linux_x86_64.whl"
|
| 28 |
+
curl -L -o "/tmp/${W}" "https://github.com/turboderp-org/exllamav3/releases/download/v1.5.1/${W/+/%2B}"
|
| 29 |
+
"${PIP}" install "/tmp/${W}"
|
| 30 |
+
|
| 31 |
+
echo "[4/6] OrcaSAQ2-kernel (EXL3 plugin for vLLM)"
|
| 32 |
+
rm -rf /tmp/OrcaSAQ2-kernel
|
| 33 |
+
git clone https://github.com/Continuum-AI-Corp/OrcaSAQ2-kernel /tmp/OrcaSAQ2-kernel
|
| 34 |
+
git -C /tmp/OrcaSAQ2-kernel checkout "${ORCASAQ2_COMMIT}"
|
| 35 |
+
"${PIP}" install -e /tmp/OrcaSAQ2-kernel
|
| 36 |
+
|
| 37 |
+
echo "[5/6] hybridagent engine-side plugins (backend + processor)"
|
| 38 |
+
"${PIP}" install -e ./backend -e ./processor
|
| 39 |
+
|
| 40 |
+
echo "[6/6] overlay the fork onto the installed vLLM v1 runner"
|
| 41 |
+
SP="$("${VENV}/bin/python" -c 'import vllm, os; print(os.path.dirname(vllm.__file__))')"
|
| 42 |
+
cp -v fork/v1/request.py "${SP}/v1/request.py"
|
| 43 |
+
cp -v fork/v1/worker/gpu_model_runner.py "${SP}/v1/worker/gpu_model_runner.py"
|
| 44 |
+
cp -v fork/v1/core/kv_cache_manager.py "${SP}/v1/core/kv_cache_manager.py"
|
| 45 |
+
cp -v fork/v1/core/sched/output.py "${SP}/v1/core/sched/output.py"
|
| 46 |
+
cp -v fork/v1/core/sched/scheduler.py "${SP}/v1/core/sched/scheduler.py"
|
| 47 |
+
|
| 48 |
+
echo
|
| 49 |
+
echo "Setup complete. Serve with (MODEL_DIR = the repo root, which holds the weights):"
|
| 50 |
+
echo " cd .."
|
| 51 |
+
echo " HYBRIDAGENT_MODEL_DIR=\$(pwd) ${VENV}/bin/python serving/serve.py"
|