Train an EAGLE-3 draft head
A draft head learns to predict what the target model will say next. Its training data must therefore be the target’s own output, not human-written responses. This page builds that data, chat and agent traces, with Qwen3-8B itself, then fine-tunes the public EAGLE-3 head on it.
Configure models and hardware
Everything that changes between clusters lives in config.py.
# The model you serve (the verifier) and the public EAGLE-3 draft head you start from.
TARGET_MODEL = "Qwen/Qwen3-8B"
PUBLIC_DRAFTER = "RedHatAI/Qwen3-8B-speculator.eagle3"
# Number of tokens the draft head proposes per verification step.
NUM_SPECULATIVE_TOKENS = 3
# One GPU type for every GPU task. Qwen3-8B in bf16 needs about 17 GB, so any 40 GB+ card
# works. On H100 or H200, change this one line.
GPU = "L40s"
The pipeline starts from RedHatAI/Qwen3-8B-speculator.eagle3, a general-purpose EAGLE-3
head trained by Red Hat. Fine-tuning it on your traffic is cheaper than training a head from
scratch and keeps its general drafting ability. Set init_from="" on train_drafter to
train from scratch instead. The same task trains the DFlash and DSpark
drafters with a different method.
One GPU image runs vLLM for data generation, serving, and benchmarks, and speculators for
training:
# One GPU image runs vLLM (serving, data generation, benchmarks) and speculators (training).
# vLLM 0.33 and speculators 0.9 agree on torch 2.14 and transformers 5.16-5.19.
gpu_image = (
flyte.Image.from_debian_base(name="specdec-gpu", python_version=(3, 12))
.with_apt_packages("curl", "git")
.with_pip_packages("flyteplugins-vllm==2.13.0", "unionai-reuse>=0.1.3")
.with_pip_packages(
f"vllm=={VLLM_VERSION}",
f"speculators=={SPECULATORS_VERSION}",
"transformers==5.19.0",
"arctic-inference==0.3.0",
"datasets>=4.0.0",
"httpx>=0.27",
)
# Prebuilt FlashInfer kernels, so vLLM's sampler does not JIT-compile at startup.
.with_pip_packages(
f"flashinfer-jit-cache=={FLASHINFER_VERSION}",
index_url="https://flashinfer.ai/whl/cu130",
)
)
# Orchestration, data wrangling, and reports run on a small CPU image.
# The vLLM plugin is small and pulls in no vLLM; drivers import it to define serving apps.
cpu_image = flyte.Image.from_debian_base(name="specdec-cpu", python_version=(3, 12)).with_pip_packages(
"flyteplugins-vllm==2.13.0", "datasets>=4.0.0", "httpx>=0.27"
)
vLLM 0.33 and speculators 0.9 can share one environment because they agree on torch 2.14
and transformers 5.16 to 5.19. Pin all three. A newer transformers release can break that
overlap.
Resource requests are sized to the nodes they land on. A request above a node’s allocatable CPU or memory never schedules, even when the GPU is free:
# Requests sized to the node shapes they land on. The defaults fit AWS L40S nodes:
# g6e.2xlarge (1 GPU, ~7.9 allocatable vCPU, ~61 GiB) and g6e.12xlarge (4 GPUs, 48 vCPU,
# 384 GiB). CPU-only tasks fit a t3a.xlarge (~3.9 vCPU, ~14.5 GiB). Leave headroom below a
# node's allocatable capacity, or the pod never schedules.
ONE_GPU = flyte.Resources(cpu=6, memory="48Gi", gpu=f"{GPU}:1", disk="100Gi", shm="auto")
FOUR_GPUS = flyte.Resources(cpu=40, memory="320Gi", gpu=f"{GPU}:4", disk="200Gi", shm="auto")
# Training needs a vLLM server for hidden states and the trainer. With 4 GPUs, two serve and
# two train. With 1, both share one GPU: slower, but it fits a single 48 GB card.
TRAIN_NUM_GPUS = 1
TRAIN_NUM_GPUS picks the training layout. With 1, the hidden-state server and the
trainer share one 48 GB GPU, which fits any cluster that can serve the model. With 4, the
task takes a whole 4-GPU node: two GPUs serve hidden states and two train, which is several
times faster.
Each stage gets an environment sized for its work:
data_env = flyte.TaskEnvironment(
name="specdec-data",
image=cpu_image,
resources=flyte.Resources(cpu=2, memory="12Gi", disk="20Gi"),
)
# Data generation keeps one vLLM server warm per replica, so each shard skips the model load.
generate_env = flyte.TaskEnvironment(
name="specdec-generate",
image=gpu_image,
resources=ONE_GPU,
reusable=flyte.ReusePolicy(replicas=(1, 4), concurrency=1, idle_ttl=600, scaledown_ttl=300),
)
# Online training: vLLM serves hidden states while the trainer consumes them.
train_env = flyte.TaskEnvironment(
name="specdec-train",
image=gpu_image,
resources=FOUR_GPUS if TRAIN_NUM_GPUS == 4 else ONE_GPU,
)
# Each benchmark task owns one GPU and one vLLM server configuration.
bench_env = flyte.TaskEnvironment(
name="specdec-bench",
image=gpu_image,
resources=ONE_GPU,
)
# The driver only orchestrates. depends_on registers every environment it calls.
driver_env = flyte.TaskEnvironment(
name="specdec-driver",
image=cpu_image,
resources=flyte.Resources(cpu=2, memory="4Gi"),
depends_on=[data_env, generate_env, train_env, bench_env],
)
generate_env is a reusable environment. Up to four replicas stay alive between tasks, so
the vLLM server a replica starts for its first shard serves every later shard too. Loading
Qwen3-8B and capturing CUDA graphs takes several minutes, which would otherwise repeat for
every shard.
Collect target-generated data
Sample prompts
prepare_prompts samples UltraChat conversations for general chat and ToolACE tasks for
agent traffic, and splits each into shards:
@data_env.task(cache="auto", report=True)
async def prepare_prompts(num_chat: int, num_agent: int, num_shards: int) -> PromptShards:
"""Sample prompts and split them into shards for parallel generation."""
async def write_shards(rows: list[dict], prefix: str) -> list[File]:
files = []
for i in range(num_shards):
path = f"/tmp/{prefix}-{i}.jsonl"
with open(path, "w") as f:
for row in rows[i::num_shards]:
f.write(json.dumps(row) + "\n")
files.append(await File.from_local(path))
return files
chat = [{"messages": m} for m in load_chat_prompts(num_chat)]
agent = load_agent_tasks("train", num_agent)
await flyte.report.replace.aio(data_reports.prompts_report(chat, agent, num_shards))
await flyte.report.flush.aio()
return PromptShards(
chat=await write_shards(chat, "chat"),
agent=await write_shards(agent, "agent"),
)
ToolACE is split three ways in workloads.py. The first 60% is initial training data, the
next 20% stands in for production traffic in the retraining loop, and the last 20% is held
out for benchmarks. No benchmark prompt is ever trained on.
Regenerate chat responses
speculators regenerate-responses sends each conversation to Qwen3-8B and writes the
response in tokenized form, with a loss mask over the assistant tokens:
@generate_env.task(cache="auto", report=True)
async def regenerate_chat(shard: File) -> Shard:
"""Replace the dataset's responses with Qwen3-8B's own, tokenized for training."""
server = _get_server()
local = await shard.download()
out = "/tmp/regenerated.jsonl"
subprocess.run(
[
"speculators", "regenerate-responses",
"--endpoint", f"{server.base_url}/v1/chat/completions",
"--dataset", local,
"--outfile", out,
"--max-tokens", "2048",
"--concurrency", "64",
"--sampling-params", json.dumps({"temperature": 0.7, **NO_THINKING}),
],
check=True,
)
with open(out) as f:
rows = [json.loads(line) for line in f]
with open(local) as f:
requested = sum(1 for _ in f)
lengths = [sum(r["loss_mask"]) for r in rows]
result = Shard(
data=await File.from_local(out),
kind="chat",
rows=len(rows),
tokens=sum(len(r["input_ids"]) for r in rows),
target_tokens=sum(lengths),
failed=max(requested - len({r.get("primary_id") for r in rows}), 0),
lengths=lengths,
)
await flyte.report.replace.aio(data_reports.chat_shard_report(result, rows))
await flyte.report.flush.aio()
return result
The sampling temperature is 0.7, not 0. A head trained only on greedy outputs drafts poorly for requests served with sampling. Thinking is disabled because this tutorial’s traffic runs without it. If your application uses Qwen3’s thinking mode, regenerate with it on so the head learns to draft reasoning too.
Record agent traces
ToolACE rows carry tool schemas, a user request, and recorded tool results. The agent sends the request and the tools to Qwen3-8B, and when the model calls a tool, replays the recorded result:
Complete = Callable[[list[dict], list[dict]], Awaitable[dict]]
def http_complete(
client: httpx.AsyncClient,
base_url: str,
*,
api_key: str | None = None,
model: str = TARGET_MODEL,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> Complete:
"""An OpenAI-compatible chat completion call that returns the assistant message."""
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
async def complete(messages: list[dict], tools: list[dict]) -> dict:
response = await client.post(
f"{base_url}/v1/chat/completions",
headers=headers,
json={
"model": model,
"messages": messages,
"tools": tools,
"temperature": temperature,
"max_tokens": max_tokens,
**NO_THINKING,
},
timeout=300,
)
response.raise_for_status()
return response.json()["choices"][0]["message"]
return complete
async def run_agent(complete: Complete, task: dict, max_rounds: int = 3) -> dict:
"""Run one tool-calling episode and return it as a trace.
The trace is the full conversation in OpenAI chat format plus the tool schemas, which
is exactly what a draft model trains on.
"""
messages: list[dict] = [
{"role": "system", "content": AGENT_SYSTEM_PROMPT},
{"role": "user", "content": task["request"]},
]
replay = list(task["tool_results"])
for _ in range(max_rounds):
message = await complete(messages, task["tools"])
assistant = {"role": "assistant", "content": message.get("content") or ""}
if message.get("tool_calls"):
assistant["tool_calls"] = [
{"id": c["id"], "type": "function", "function": c["function"]}
for c in message["tool_calls"]
]
messages.append(assistant)
if not message.get("tool_calls"):
break
# Replay the recorded tool output instead of calling a live API.
for call in message["tool_calls"]:
result = replay.pop(0) if replay else json.dumps({"status": "ok"})
messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
return {"messages": messages, "tools": task["tools"]}
Each episode becomes one trace: the full conversation in OpenAI chat format plus the tool schemas. Every assistant turn in it is target output, including the JSON tool-call arguments.
@generate_env.task(cache="auto", report=True)
async def collect_agent_traces(shard: File, concurrency: int = 32) -> Shard:
"""Run each agent task against Qwen3-8B and record the full episode."""
import httpx
from transformers import AutoTokenizer
server = _get_server()
with open(await shard.download()) as f:
tasks = [json.loads(line) for line in f]
semaphore = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
complete = http_complete(client, server.base_url)
async def one(task: dict) -> dict | None:
async with semaphore:
try:
return await run_agent(complete, task)
except Exception as e: # A malformed tool schema should not sink the shard.
print(f"agent episode failed: {e}")
return None
traces = [t for t in await asyncio.gather(*(one(t) for t in tasks)) if t]
out = "/tmp/agent-traces.jsonl"
with open(out, "w") as f:
for trace in traces:
f.write(json.dumps(trace) + "\n")
tokenizer = AutoTokenizer.from_pretrained(TARGET_MODEL)
stats = [data_reports.trace_stats(t, tokenizer) for t in traces]
result = Shard(
data=await File.from_local(out),
kind="agent",
rows=len(traces),
tokens=sum(s["tokens"] for s in stats),
target_tokens=sum(s["target_tokens"] for s in stats),
tool_calls=sum(s["tool_calls"] for s in stats),
failed=len(tasks) - len(traces),
lengths=[s["target_tokens"] for s in stats],
)
await flyte.report.replace.aio(data_reports.agent_shard_report(result, traces, stats))
await flyte.report.flush.aio()
return result
Report what each shard produced
Each generation task returns a Shard: the data file plus counts the later stages and
reports use, so nothing downstream has to re-read the file to know what is in it:
@dataclass
class Shard:
"""One shard of generated training data and what is in it."""
data: File
kind: str # "chat" or "agent"
rows: int = 0
tokens: int = 0 # Tokens the trainer sees, prompt included.
target_tokens: int = 0 # Tokens written by the target model: what the draft learns.
tool_calls: int = 0
failed: int = 0
lengths: list[int] = field(default_factory=list) # Target tokens per row.
@dataclass
class TrainingData:
chat: list[Shard]
agent: list[Shard]
@property
def files(self) -> list[File]:
return [s.data for s in self.chat + self.agent]
Every data task also renders a report:
prepare_promptsshows the sampled prompts: counts, prompt lengths, and tools per agent task.regenerate_chatshows each shard’s rows, target-written tokens, and response length distribution, with sample rows as the trainer sees them after the chat template.collect_agent_tracesshows episodes, tool-call rate, turns per episode, and the most called tools, with a rendered example episode.build_datasetshows the final mix: chat against agent tokens, and per-shard totals.
Check these before you spend GPU hours on training. A shard with many failed episodes or a
response length distribution piled up at --max-tokens means the data, not the trainer,
needs fixing.
Fan out the shards
build_dataset runs the chat and agent shards concurrently:
@driver_env.task(report=True)
async def build_dataset(num_chat: int, num_agent: int, num_shards: int = 4) -> TrainingData:
"""Generate the full training set, every shard in parallel."""
shards = await prepare_prompts(num_chat, num_agent, num_shards)
with flyte.group("regenerate-chat"):
chat = asyncio.gather(*(regenerate_chat(s) for s in shards.chat))
with flyte.group("agent-traces"):
agent = asyncio.gather(*(collect_agent_traces(s) for s in shards.agent))
chat_shards, agent_shards = await asyncio.gather(chat, agent)
data = TrainingData(chat=list(chat_shards), agent=list(agent_shards))
await flyte.report.replace.aio(data_reports.dataset_report(data))
await flyte.report.flush.aio()
return data
prepare_prompts, regenerate_chat, and collect_agent_traces all use cache="auto". A
second pipeline run with the same data sizes reuses every shard and goes straight to
training.
Fine-tune the head in online mode
EAGLE-3 trains on the target’s hidden states from three layers. Precomputing them offline costs about 32 KB per token for an 8B model, which is terabytes for a dataset of a few hundred million tokens. In online mode, a vLLM server computes hidden states on demand and the trainer consumes and discards them.
The hidden-state server is ordinary vllm serve with two extra flags. One tells vLLM which
of the target’s layers to capture. The other attaches a connector that writes them to a
shared path for every request:
What to train is a recipe. The EAGLE-3 recipe names the public head to start from, the three target layers it reads, its serving draft length, and its training flags:
EAGLE3 = Recipe(
method="eagle3",
init_from=PUBLIC_DRAFTER,
target_layers=(2, 18, 33),
num_speculative_tokens=3,
lr=5e-5,
epochs=2,
# Training-time test: unroll the draft on its own predictions for 3 steps.
train_args=("--ttt-steps", "3"),
)
train_drafter starts that server, then runs the trainer with torchrun against it. On
/dev/shm, hidden states move between the two processes through memory and are deleted as
soon as the trainer reads them:
@train_env.task(cache="auto", retries=1, report=True)
async def train_drafter(
data: list[File],
method: str = "eagle3",
init_from: str | None = None,
warm_start: Dir | None = None,
epochs: int | None = None,
lr: float | None = None,
max_steps: int | None = None,
) -> Dir:
"""Fine-tune (or train from scratch) a draft model on target-generated data.
`method` picks a recipe: "eagle3", "dflash", or "dspark". `warm_start` continues from a
previous run's head. Otherwise `init_from` overrides the recipe's Hugging Face head; an
empty string trains from scratch. Unset arguments come from the recipe.
The task is cached: the same data and arguments return the drafter already trained, so
the benchmark reuses what each family's pipeline trained.
"""
recipe = RECIPES[method]
init_from = recipe.init_from if init_from is None else init_from
lr = recipe.lr if lr is None else lr
epochs = recipe.epochs if epochs is None else epochs
shared_gpu = VLLM_GPUS == TRAIN_GPUS
# Hidden-state server: vLLM capturing exactly the layers this drafter reads.
server = start_server(
gpus=VLLM_GPUS,
hidden_states_path=HIDDEN_STATES,
target_layers=recipe.target_layers,
extra_args=[
# Rendering conversations that carry tool schemas needs the tool parser.
*TOOL_ARGS,
"--data-parallel-size", str(VLLM_REPLICAS),
"--max-model-len", str(recipe.seq_len + 512),
"--gpu-memory-utilization", str(recipe.vllm_memory if shared_gpu else 0.85),
],
)
start = "previous drafter" if warm_start else (init_from or "scratch")
history = train_report.History(
method=method,
config={
"method": method,
"start from": start,
"data files": str(len(data)),
"epochs": str(epochs),
"max steps": str(max_steps or "all"),
"learning rate": str(lr),
"target layers": ", ".join(map(str, recipe.target_layers)),
"method flags": " ".join(recipe.train_args),
"sequence length": str(recipe.seq_len),
"GPUs": f"vLLM {VLLM_GPUS}, trainer {TRAIN_GPUS}",
},
)
try:
# Tokenize every file with the target's chat template. Regenerated chat files
# are already tokenized; agent traces are rendered through the server.
local = [_for_prepare_data(await f.download()) for f in data]
prepare_data(local, server.base_url, "/tmp/prepared", recipe.seq_len)
_check_prepared(local, "/tmp/prepared")
method_args = list(recipe.train_args)
if warm_start is not None or init_from:
# A pretrained head fixes its own architecture and vocabulary.
method_args = _without(method_args, ARCHITECTURE_FLAGS)
source = await warm_start.download() if warm_start is not None else init_from
draft_args = ["--from-pretrained", source]
else:
draft_args = []
save_path = Path("/tmp/checkpoints")
await run_and_chart(
[
"torchrun", "--standalone", "--nproc_per_node", str(TRAINERS),
"-m", "speculators.train",
"--speculator-type", method,
"--verifier-name-or-path", TARGET_MODEL,
*draft_args,
"--target-layer-ids", *map(str, recipe.target_layers),
*method_args,
"--data-path", "/tmp/prepared",
"--vllm-endpoint", f"{server.base_url}/v1",
"--hidden-states-path", HIDDEN_STATES,
"--on-missing", "generate",
"--save-path", str(save_path),
"--save-best",
"--epochs", str(epochs),
"--lr", str(lr),
"--total-seq-len", str(recipe.seq_len),
"--num-workers", "4",
*(["--max-steps", str(max_steps)] if max_steps else []),
],
# A wide console keeps each metric line on one line for the parser.
env={
**os.environ,
"CUDA_VISIBLE_DEVICES": TRAIN_GPUS,
# Sharing a GPU with vLLM leaves free memory fragmented; let allocations grow.
"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True",
"COLUMNS": "8000",
},
history=history,
)
finally:
server.stop()
# Upload only what vLLM needs to serve the head: config and weights.
best = _best_checkpoint(save_path)
out = Path("/tmp/drafter")
out.mkdir()
for f in best.iterdir():
if f.suffix in {".json", ".safetensors", ".py"}:
shutil.copy(f, out / f.name)
history.checkpoint = best.name
await flyte.report.replace.aio(train_report.render(history, done=True))
await flyte.report.flush.aio()
return await Dir.from_local(str(out))
The trainer prints one metric line per optimizer step. run_and_chart streams those lines,
parses them, and refreshes the task’s report every 20 seconds:
async def run_and_chart(cmd: list[str], env: dict, history: train_report.History) -> None:
"""Run the trainer, parse its metric lines, and refresh the report as it trains."""
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
limit=16 * 1024 * 1024, # Long metric and config lines can exceed asyncio's 64 KB.
)
recent: deque[str] = deque(maxlen=80)
last_flush = 0.0
async for raw in process.stdout:
# The trainer's rich console pads each line to the terminal width; collapse it.
line = re.sub(r" {3,}", " ", raw.decode(errors="replace")).rstrip()
print(line, flush=True)
recent.append(line)
if history.parse(line) and time.monotonic() - last_flush > 20:
await flyte.report.replace.aio(train_report.render(history))
await flyte.report.flush.aio()
last_flush = time.monotonic()
if await process.wait() != 0:
errors = [l for l in recent if re.search(r"Error|error:|Traceback|OutOfMemory", l)]
detail = "\n".join(errors[-10:] or list(recent)[-10:])
raise RuntimeError(f"training failed with exit code {process.returncode}:\n{detail}")
The report charts training loss and draft accuracy at each position, tabulates validation
metrics per epoch, and records the configuration. Position 0 is the first drafted token.
Each later position counts only when every earlier one was accepted, so acceptance length
is roughly 1 + acc0 + acc0 × acc1 + …. Watch position 0: if it does not rise above the
public head’s starting point, more training will not help.
Things to know about the training settings:
-
Two kinds of training data, one dataset. Regenerated chat files are already tokenized. Agent traces are raw conversations, which
prepare-datarenders through the vLLM server’s chat template endpoint, tool schemas included.prepare_dataprepares each kind separately and merges them, and the task fails if fewer rows come out than went in:def prepare_data(paths: list[str], render_endpoint: str, out: str, seq_len: int) -> None: """Tokenize training files with the target's chat template into one dataset. Regenerated chat rows arrive tokenized; agent traces are rendered through the vLLM server. speculators 0.9 cannot concatenate the two kinds in one call (their token id types differ), so each kind is prepared on its own and the results merged. """ from datasets import concatenate_datasets, load_from_disk from speculators.train.vocab_mapping import save_token_frequency_distribution groups = [g for g in ([p for p in paths if _is_tokenized(p)], [p for p in paths if not _is_tokenized(p)]) if g] parts = [] for i, group in enumerate(groups): part = f"{out}-{i}" subprocess.run( [ "speculators", "prepare-data", "--model", TARGET_MODEL, *[arg for path in group for arg in ("--data", path)], "--output", part, "--seq-length", str(seq_len), "--render-endpoint", render_endpoint, ], check=True, ) parts.append(load_from_disk(part)) # Cast every part to 64-bit token ids, then merge and shuffle. target = next((p.features for p in parts if "int64" in str(p.features["input_ids"])), parts[0].features) merged = concatenate_datasets([p.cast(target) for p in parts]).shuffle(seed=0) merged.save_to_disk(out) # Token frequencies pick the reduced draft vocabulary for drafters trained from scratch. save_token_frequency_distribution(merged, f"{out}/token_freq.pt")Rendering conversations that carry tool schemas needs vLLM’s tool-calling flags on the hidden-state server. Without them, every agent trace fails to render, and without the row check, training would silently run on chat data alone.
-
The training-time-test depth is 3. The draft is unrolled on its own predictions for
--ttt-stepssteps. Match it tonum_speculative_tokensat serving time, or raise it for math and code, where longer drafts get accepted. -
The learning rate is low for fine-tuning.
5e-5adapts the public head without erasing it. Training from scratch uses about1e-4and several epochs. -
Only the config and weights are uploaded. The returned
Diris the format vLLM loads with--speculative-config, so the serving app and benchmarks consume it directly.
Run the pipeline
main generates the dataset and fine-tunes the head:
@driver_env.task
async def main(
num_chat: int = 4000,
num_agent: int = 4000,
num_shards: int = 4,
max_steps: int | None = None,
) -> Dir:
"""Generate the dataset and fine-tune the public EAGLE-3 head on it."""
dataset = await build_dataset(num_chat, num_agent, num_shards)
return await train_drafter(data=dataset.files, method="eagle3", max_steps=max_steps)
if __name__ == "__main__":
flyte.init_from_config()
run = flyte.run(main, **(SMOKE if "--smoke" in sys.argv else FULL))
print(run.url)
Start with a smoke run, which uses 64 prompts per source and 20 training steps:
python pipeline.py --smokeThen run the full pipeline:
python pipeline.pytrain_drafter is cached along with the data, so rerunning with the same inputs returns the
trained head immediately. Changing a training argument reruns only training.
Benchmark draft models measures the head against the other families.