Build a Self-Hosted Support Ticket Triage Service with Qwen3.5-4B
In late June 2026, vLLM shipped v0.21: speculative decoding support for reasoning models, KV cache offload, and Model Runner V2 becoming the default for dense Llama and Mistral models.

Introduction
In late June 2026, vLLM shipped v0.21: speculative decoding support for reasoning models, KV cache offload, and Model Runner V2 becoming the default for dense Llama and Mistral models. That release is the missing half of a build a lot of teams keep postponing: a support ticket triage service that runs entirely on your own hardware. The model half has been sitting there since Alibaba released Qwen3.5-4B under Apache 2.0 earlier this year, a dense 4-billion-parameter model with a 262K context window that fits on a single mid-range GPU. In this tutorial you serve Qwen3.5-4B with vLLM, force its output into a validated triage schema, wrap it in a FastAPI service with routing rules, and then use v0.21's speculative decoding to roughly double your throughput with a draft model.
What triage actually requires
Strip the product language and ticket triage is three operations: classify (urgency, category), extract (product area, sentiment, a one-line summary), and route (which queue, which alert). Keyword rules do this badly for well-known reasons: "the export worked fine until today" contains no negative keyword, "URGENT!!!" from a user who flags everything urgent contains nothing but one, and a quarter of your tickets arrive in Spanish. Classification with a language model solves the paraphrase problem, the tone problem, and the language problem in one move. It does not require a frontier model. Deciding whether a ticket about failed payments is urgent is not graduate-level reasoning, and paying frontier per-token prices for it is how inference bills quietly triple.
What vLLM v0.21 gives you
vLLM is the open-source inference engine that turns a Hugging Face checkpoint into a production OpenAI-compatible API. Three of its properties carry this build. Continuous batching: concurrent requests share the GPU efficiently, so a backlog drain and live traffic coexist. Structured output: vLLM constrains generation with a grammar compiled from your JSON schema, so the model cannot emit anything that fails validation. And as of v0.21, speculative decoding: a small draft model proposes tokens the 4B verifies in batches, which is the difference between one GPU handling hundreds versus thousands of tickets per hour.
Serve Qwen3.5-4B
One command after one pip install. The 262K native context is overkill for tickets, and capping it saves memory for batching.
pip install "vllm>=0.21"
vllm serve Qwen/Qwen3.5-4B \
--max-model-len 16384 \
--gpu-memory-utilization 0.90 \
--port 8000The first launch pulls the checkpoint from Hugging Face. A 4B model in bf16 wants roughly 9 GB of VRAM before KV cache, so this runs on a single RTX 4090 or an L4 instance, hardware most teams already have idling somewhere. If VRAM is tighter than that, the AWQ-quantized variant of the same checkpoint drops the footprint under half at a small accuracy cost.
Define the schema the model must obey
The schema is the real interface of the service. Everything downstream trusts it, so make it enum-heavy: free text is where triage systems rot.
from enum import Enum
from pydantic import Field, BaseModel
class Urgency(str, Enum):
critical = "critical"
high = "high"
normal = "normal"
low = "low"
class Category(str, Enum):
billing = "billing"
bug = "bug"
how_to = "how_to"
feature_request = "feature_request"
account_access = "account_access"
class TriageResult(BaseModel):
urgency: Urgency
category: Category
sentiment: str = Field(pattern="^(angry|frustrated|neutral|positive)$")
product_area: str
summary: str = Field(max_length=200)
reply_language: strreply_language is the field teams forget. Detecting that the ticket arrived in Portuguese costs the model nothing extra and saves the agent from opening a translator to find out.
Call the model with guided decoding
vLLM's OpenAI-compatible server accepts the schema through extra_body, and the guided decoder guarantees the response parses. Not "usually parses". Parses.
from openai import OpenAI
triage_llm = OpenAI(
api_key="unused",
base_url="http://localhost:8000/v1",
)
TRIAGE_RULES = """Classify the support ticket.
critical = production down, data loss, or security incident.
high = a paying user is blocked with no workaround.
normal = degraded experience, workaround exists.
low = questions, cosmetic issues, feature wishes.
Judge urgency by impact described, never by the customer's tone."""
def triage_ticket(subject: str, body: str) -> TriageResult:
response = triage_llm.chat.completions.create(
model="Qwen/Qwen3.5-4B",
messages=[
{"role": "system", "content": TRIAGE_RULES},
{"role": "user", "content": f"Subject: {subject}\n\n{body}"},
],
temperature=0,
extra_body={"guided_json": TriageResult.model_json_schema()},
)
return TriageResult.model_validate_json(
response.choices[0].message.content
)The line in TRIAGE_RULES about tone is doing more work than it looks like. Without it, every ticket written in capital letters drifts toward critical, and your on-call engineer learns to ignore the pager, which defeats the entire exercise.
Wrap it in a service with deterministic routing
The model classifies. It does not decide who gets paged. Routing stays in plain code where you can test it, audit it, and change it without touching a prompt.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="ticket-triage")
class Ticket(BaseModel):
subject: str
body: str
ROUTES = {
("critical", "bug"): "pagerduty:oncall",
("critical", "account_access"): "pagerduty:oncall",
("high", "billing"): "queue:billing-priority",
}
@app.post("/triage")
def triage(ticket: Ticket):
result = triage_ticket(ticket.subject, ticket.body)
destination = ROUTES.get(
(result.urgency.value, result.category.value),
f"queue:{result.category.value}",
)
return {"triage": result.model_dump(), "route": destination}This split earns its keep the first time someone asks "why did this page me at 3 am". The answer is two dictionary lines, not an archaeology dig through model outputs.
Drain a backlog concurrently
vLLM's continuous batching means the right client-side pattern is firing requests concurrently and letting the server schedule them.
import asyncio
from openai import AsyncOpenAI
async_llm = AsyncOpenAI(api_key="unused", base_url="http://localhost:8000/v1")
async def triage_backlog(tickets: list[Ticket], parallel: int = 32):
gate = asyncio.Semaphore(parallel)
async def one(t: Ticket):
async with gate:
resp = await async_llm.chat.completions.create(
model="Qwen/Qwen3.5-4B",
messages=[
{"role": "system", "content": TRIAGE_RULES},
{"role": "user", "content": f"Subject: {t.subject}\n\n{t.body}"},
],
temperature=0,
extra_body={"guided_json": TriageResult.model_json_schema()},
)
return TriageResult.model_validate_json(
resp.choices[0].message.content
)
return await asyncio.gather(*(one(t) for t in tickets))Thirty-two in flight keeps a single GPU saturated without starving the live /triage endpoint. Tune it by watching vLLM's logged batch occupancy, not by guessing.
Double the throughput with v0.21 speculative decoding
This is the section the vLLM release makes possible. Add a draft model, and Qwen3.5-0.8B is the natural drafter for its own family: same tokenizer, same conventions, one-fifth the size.
vllm serve Qwen/Qwen3.5-4B \
--max-model-len 16384 \
--gpu-memory-utilization 0.90 \
--speculative-config '{"model": "Qwen/Qwen3.5-0.8B", "num_speculative_tokens": 5}' \
--port 8000The mechanics: the 0.8B proposes five tokens, the 4B verifies them in one forward pass, and every accepted proposal is a token you generated at draft-model cost. Acceptance rates run high on triage output because guided JSON is predictable, exactly the regime speculative decoding loves. Measure it on your own tickets; structured classification workloads are close to the best case for this technique, and unmeasured performance claims are how this industry got its reputation.
Self-hosted Qwen3.5-4B vs a frontier API for triage
| Dimension | Qwen3.5-4B + vLLM | Frontier API |
|---|---|---|
| Cost per 100K tickets | GPU-hours, flat | Per-token, linear |
| Ticket data leaves your infra | No | Yes |
| Latency | Single-digit seconds, local | Network + queue, variable |
| Quality ceiling | Enough for classification | Higher, wasted on this task |
| Schema guarantee | Grammar-enforced by vLLM | Vendor JSON mode, usually honored |
| Ops burden | Yours: GPU, upgrades, monitoring | Vendor's |
What will go wrong
Guided decoding guarantees the JSON is valid, not that it is true: a confused model produces a well-formed wrong answer, so sample a percentage of classifications into a human review queue and track disagreement. A 4B model misreads sarcasm, mixed-language tickets, and domain jargon more than a frontier model does; expect a few points of accuracy ceiling as the price of data residency. Speculative decoding gains collapse when output is unpredictable, so benchmark with the draft model on and off before promising numbers to anyone. And self-hosting means the 2 am GPU alert is now your alert. The vendor you fired was also an ops team.
When to use it
This build pays off when ticket volume makes per-token pricing hurt, when support tickets contain customer data that should not transit a third party, or when triage latency needs to be local-network fast. Start with the plain 4B server, measure accuracy against two weeks of human-triaged tickets, then turn on the draft model and re-measure throughput. If accuracy comes up short, the same vLLM command serves Qwen3.5-9B or 27B with one string changed. The serving layer, the schema, and the routing code all survive the model swap untouched, which is what a well-built LLM service looks like: the model is the least permanent part of it.
You might also like
Keep reading from the journal.
June 30, 2026AI
Audit the hiring model before a regulator does
Accurate and unfair are not the same number
July 6, 2026AI
The AI feature stuck on your roadmap has a price tag
The unshipped feature has a cost. Buying the outcome caps it.
July 3, 2026AI
From Prompt to Workflow: Multi-Step Python Agents with Microsoft & Copilot
At Microsoft Build 2026 in early June, the Microsoft Agent Framework reached general availability and the GitHub Copilot SDK hit 1.0 support inside it for both