Author: Anthony Etim
Date created: 2026/08/10
Last modified: 2026/08/10
Description: An introduction to serving KerasHub models with vLLM on TPU.
This guide shows how to serve a KerasHub CausalLM through vLLM's TPU backend.
You do not convert the model, export the weights, or reimplement the
architecture. The preset loads with from_preset the way it always does, and
the same KerasHub layers run the forward pass.
The entry point is one class:
from keras_hub.vllm import KerasHubLLM
llm = KerasHubLLM("keras_hub:gemma3_instruct_1b")
KerasHub models generate text with CausalLM.generate(), which decodes a fixed
batch of prompts in one static loop. That works well for experiments and for
scoring a dataset offline. It works less well for serving, where requests
arrive one at a time, have different lengths, and finish at different steps. A
static loop makes every request in the batch wait for the longest one.
vLLM is built for that case. It schedules requests continuously, keeps the KV cache in fixed-size pages so memory is not reserved for padding, and runs attention with a kernel written for paged memory.
This guide needs a TPU runtime. On Colab, pick one under Runtime > Change runtime type.
Install KerasHub and the TPU build of vLLM, which brings in tpu-inference.
Both pins are needed: Colab's TPU image has no keras-hub, and its flax
0.11.2 predates what tpu-inference requires.
!pip uninstall -y torchaudio -q
!pip install -q --no-warn-conflicts vllm-tpu
!pip install -q --no-warn-conflicts 'keras-hub>=0.31.0'
!pip install -q --no-deps --no-warn-conflicts --force-reinstall git+https://github.com/vllm-project/tpu-inference
!pip install -q --no-warn-conflicts flax==0.12.8
[?25h
A few environment variables have to be set before Keras or vLLM is imported.
Set KERAS_BACKEND to jax and KERAS_NNX_ENABLED to true to run Keras on
the JAX backend with NNX enabled. NNX is not optional here: it makes the
backbone's variables NNX state, which is how the TPU runner carries the weights
without a conversion step. JAX_PLATFORMS and VLLM_TARGET_DEVICE point JAX
and vLLM at the TPU. XLA_PYTHON_CLIENT_PREALLOCATE and
XLA_PYTHON_CLIENT_ALLOCATOR stop JAX from preallocating the whole device,
which would leave nothing for the vLLM KV cache.
VLLM_ENABLE_V1_MULTIPROCESSING keeps the vLLM engine in this process instead
of a subprocess, so its errors surface in the notebook, and
VLLM_LOGGING_LEVEL quietens the engine's routine progress logging.
import os
import warnings
# About the VM image rather than this guide.
warnings.filterwarnings("ignore", message=".*hugepages.*")
os.environ["KERAS_BACKEND"] = "jax"
os.environ["KERAS_NNX_ENABLED"] = "true"
os.environ["JAX_PLATFORMS"] = "tpu,cpu"
os.environ["VLLM_TARGET_DEVICE"] = "tpu"
os.environ["XLA_PYTHON_CLIENT_PREALLOCATE"] = "false"
os.environ["XLA_PYTHON_CLIENT_ALLOCATOR"] = "platform"
os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0"
os.environ["VLLM_LOGGING_LEVEL"] = "ERROR"
Most presets download from Kaggle without credentials. The gated ones (Gemma
and Llama, among others) need you to accept the license on the model's Kaggle
page and to set KAGGLE_USERNAME and KAGGLE_KEY. On Colab, add them in the
Secrets panel (the key icon in the left sidebar) and enable notebook access.
try:
from google.colab import userdata
for secret in ("KAGGLE_USERNAME", "KAGGLE_KEY"):
try:
os.environ[secret] = userdata.get(secret)
except Exception:
pass
except ImportError:
pass
One last piece of setup. The first time a model runs, XLA compiles it, which takes a few minutes and dominates everything else on the clock. A compilation cache means you pay that once rather than once per session.
The cache goes in a local directory here. A local directory lasts as long as the runtime, so to keep the cache across sessions on Colab, mount Drive and point the cache at it instead:
from google.colab import drive
drive.mount("/content/drive")
os.environ["JAX_COMPILATION_CACHE_DIR"] = "/content/drive/MyDrive/jax_cache"
os.environ["JAX_COMPILATION_CACHE_DIR"] = "jax_cache"
os.environ["JAX_PERSISTENT_CACHE_MIN_ENTRY_SIZE_BYTES"] = "0"
os.environ["JAX_PERSISTENT_CACHE_MIN_COMPILE_TIME_SECS"] = "0"
Pass any KerasHub CausalLM preset with the keras_hub: prefix. Behind that
call, KerasHubLLM writes a temporary model directory for the preset holding
a config file and the exported tokenizer, then starts a vLLM engine pointed at
it. That directory holds no weights and is deleted with the KerasHubLLM
object. The weights come from a normal CausalLM.from_preset call.
max_model_len caps prompt plus generated tokens. Keep it near what you
actually need: it sizes the KV cache and the shapes that get compiled, so a
large value costs memory and compile time you may not use.
vLLM logs a lot at startup, and its logger needs a real file behind stdout, which a notebook kernel does not provide. Sending the startup logs to a file covers both problems.
from contextlib import redirect_stderr
from contextlib import redirect_stdout
from keras_hub.vllm import KerasHubLLM
PRESET = "gemma3_instruct_1b"
with (
open("vllm_init.log", "w") as log,
redirect_stdout(log),
redirect_stderr(log),
):
llm = KerasHubLLM(f"keras_hub:{PRESET}", max_model_len=512)
print("model loaded")
ERROR 08-16 22:34:16 [tpu_info.py:40] Unable to poll TPU GCE Metadata. Got status code: 404 and content:
Check failed with unknown exit code: -6.
Downloading to /root/.cache/kagglehub/models/keras/gemma3/keras/gemma3_instruct_1b/3/config.json...
Downloading to /root/.cache/kagglehub/models/keras/gemma3/keras/gemma3_instruct_1b/3/tokenizer.json...
Downloading to /root/.cache/kagglehub/models/keras/gemma3/keras/gemma3_instruct_1b/3/assets/tokenizer/vocabulary.spm...
Downloading to /root/.cache/kagglehub/models/keras/gemma3/keras/gemma3_instruct_1b/3/task.json...
Downloading to /root/.cache/kagglehub/models/keras/gemma3/keras/gemma3_instruct_1b/3/model.weights.h5...
model loaded
If this raises, the reason is in vllm_init.log. The usual causes are a
missing Kaggle license acceptance and a max_model_len too large for the
memory left after the model is loaded.
generate() returns one RequestOutput per prompt. Pass a list to batch
several at once; vLLM schedules them together.
prompt = "The future of artificial intelligence is"
output = llm.generate(prompt, use_tqdm=False)[0]
print(output.prompt + output.outputs[0].text)
The future of artificial intelligence is being shaped by a complex interplay of factors - technological advancements, ethical considerations, and
Called with no SamplingParams, KerasHubLLM samples the way the preset
would sample under CausalLM.generate(). Every KerasHub task compiles itself
with CausalLM.compile's default sampler, which is top_k, and that setting
is translated into the equivalent vLLM SamplingParams for you. This is the
one place KerasHubLLM deliberately departs from vllm.LLM, which would
otherwise apply its own generic defaults and quietly change how the model
behaves.
Length is not part of a KerasHub sampler, so vLLM's default of 16 output
tokens applies. That is short. Pass SamplingParams when you want more.
from vllm import SamplingParams
greedy = SamplingParams(temperature=0.0, max_tokens=140)
output = llm.generate(prompt, greedy, use_tqdm=False)[0]
print(output.prompt + output.outputs[0].text)
The future of artificial intelligence is a topic of intense debate and speculation. While many see it as a transformative force for good, others worry about its potential dangers.
Here's a breakdown of key aspects of AI's future:
* **Narrow AI:** Currently, most AI systems are "narrow AI," meaning they excel at specific tasks. Examples include image recognition, spam filtering, and chess-playing programs.
* **General AI (AGI):** This is a hypothetical level of AI that possesses human-level intelligence – the ability to understand, learn, and apply knowledge across a wide range of tasks. It's a significant area of research, but currently doesn't exist.
* **
Explicit SamplingParams override the model's own settings completely. Use
them whenever you want reproducible output: temperature=0.0 is greedy
decoding, which makes the model pick the highest-probability token every step.
The reason to serve through vLLM is what happens under load. Here 32 requests are submitted at once, as a rough stand-in for concurrent traffic. vLLM schedules them continuously, so a request that finishes early frees its slot instead of idling until the whole batch is done.
The prompts are deliberately all different. vLLM enables prefix caching by default, so 32 copies of one prompt would let 31 of them skip most of the prefill and report a throughput you would never see on real traffic.
import time
many_prompts = [
f"Question {i}: explain in one paragraph how a computer works." for i in range(32)
]
params = SamplingParams(temperature=0.0, max_tokens=64)
llm.generate(many_prompts, params, use_tqdm=False)
start = time.perf_counter()
outputs = llm.generate(many_prompts, params, use_tqdm=False)
elapsed = time.perf_counter() - start
generated = sum(len(output.outputs[0].token_ids) for output in outputs)
print(f"{len(outputs)} requests, {generated} tokens in {elapsed:.2f}s")
print(f"{generated / elapsed:.0f} output tokens/s")
32 requests, 2048 tokens in 0.25s
8111 output tokens/s
The first generate() call is a warmup and is not timed. The engine
precompiles its shape buckets at startup, so most of the compile cost is
already paid by then, but the first call still touches things a timed run
should not have to pay for.
A KerasHub model can serve through vLLM once its attention layer has a serving route. These families have one and have been run end to end on TPU:
| Family | Example preset |
|---|---|
| GPT-2 | gpt2_base_en, gpt2_large_en |
| Llama 3 | llama3.2_instruct_1b |
| Gemma 1 | gemma_2b_en |
| Gemma 2 | gemma2_2b_en |
| Gemma 3 (text) | gemma3_instruct_1b |
| Qwen 2.5 | qwen2.5_coder_0.5b |
Everything else about a model is already generic, so adding a family means adding a route to one attention layer. A family without one fails loudly instead of falling back to something slower or wrong: the serving wrapper counts how many attention layers dispatched to the paged kernel, and raises if that does not match the number of transformer layers.
CausalLM.generate() on the same weights. The gap is small
for one request at a time and grows with concurrency and prompt length,
which is exactly the workload vLLM is for.vllm_init.log once, even on a successful run. It shows the
KV cache size vLLM settled on and how many shapes it compiled, which are
the two numbers to adjust if you run out of memory.