Tensor LabsTENSORLABS

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.

July 10, 20267 min read12 sectionsBy Ahmed Abdullah
Build a Self-Hosted Support Ticket Triage Service with Qwen3.5-4B

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.

code
pip install "vllm>=0.21"
vllm serve Qwen/Qwen3.5-4B \
--max-model-len 16384 \
--gpu-memory-utilization 0.90 \
--port 8000

The 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.

code
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: str

reply_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.

python
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.

python
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.

python
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.

code
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 8000

The 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

DimensionQwen3.5-4B + vLLMFrontier API
Cost per 100K ticketsGPU-hours, flatPer-token, linear
Ticket data leaves your infraNoYes
LatencySingle-digit seconds, localNetwork + queue, variable
Quality ceilingEnough for classificationHigher, wasted on this task
Schema guaranteeGrammar-enforced by vLLMVendor JSON mode, usually honored
Ops burdenYours: GPU, upgrades, monitoringVendor'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.