Guidance developer on retainer: constrained decoding latency grows from 0.8s to 14s after adding 20-character enum values — guidance 0.1.15 — llama.cpp tokenizer produces exponential token path expansion — 5 weeks of SLA breach — Guidance developer on monthly retainer
guidance 0.1.15 • Python 3.11 • llama.cpp 0.2.83 • Meta-Llama-3.1-8B-Instruct-Q4_K_M • 15 product category enums • 3 new 20-char values • token trie path explosion • 0.8s→14s per classification • throughput 1,250/h→252/h • fix: guidance.select() • 2.5h retainer engagement
Fairbrook Classification Systems Ltd. builds an automated customer support routing platform for enterprise software vendors. Their classification pipeline assigns incoming support tickets to one of twelve product categories, routing each ticket to the appropriate support team with a target SLA of 2-hour first response. The pipeline ran on a two-server deployment with local Meta-Llama-3.1-8B-Instruct-Q4_K_M models, using guidance to constrain the LLM output to the exact product category enum values — ensuring no hallucinated category names could slip through to the routing system. The pipeline had processed 1,250 tickets per hour at 0.8 seconds per ticket for six months without incident. On September 10, 2026, the product team expanded the category taxonomy from 12 to 15 values, adding three new enterprise product line categories: PROFESSIONAL_SERVICES, ENTERPRISE_LICENSING, and IMPLEMENTATION_SERVICES. Latency climbed to 14.3 seconds per ticket. Throughput dropped to 252 per hour. A 31,000-ticket backlog accumulated over 5 weeks. The engineering team added four additional servers; latency remained at 14 seconds per ticket. They engaged a Guidance retainer developer in October 2026.
The classification program structure before and after the schema update:
# classifier.py
# Fairbrook Classification Systems Ltd.
# guidance 0.1.15, Python 3.11, llama.cpp 0.2.83
# Meta-Llama-3.1-8B-Instruct-Q4_K_M on 2× Intel Xeon Gold 6330
import guidance
from guidance import models, gen, select
# Load local llama.cpp model
lm = models.LlamaCpp(
"models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
n_gpu_layers=0, # CPU-only inference
n_threads=48,
n_ctx=2048
)
# Product categories — BEFORE schema update (September 10)
PRODUCT_CATEGORIES_V1 = [
"BILLING", # 7 chars
"RETURNS", # 7 chars
"TECHNICAL", # 9 chars
"SHIPPING", # 8 chars
"ACCOUNT", # 7 chars
"INTEGRATION", # 11 chars
"PERFORMANCE", # 11 chars
"SECURITY", # 8 chars
"COMPLIANCE", # 10 chars
"ONBOARDING", # 10 chars
"FEATURE_REQUEST", # 15 chars
"DOCUMENTATION", # 13 chars
]
# Max length: 15 chars (FEATURE_REQUEST)
# p50 classification latency: 0.8s
# Throughput: ~1,250 tickets/hour
# Product categories — AFTER schema update (September 10)
PRODUCT_CATEGORIES_V2 = PRODUCT_CATEGORIES_V1 + [
"PROFESSIONAL_SERVICES", # 22 chars ← NEW
"ENTERPRISE_LICENSING", # 20 chars ← NEW
"IMPLEMENTATION_SERVICES", # 23 chars ← NEW
]
# Max length: 23 chars (IMPLEMENTATION_SERVICES)
# p50 classification latency: 14.3s ← 17.9× slower
# Throughput: ~252 tickets/hour
# The guidance program (unchanged from V1 to V2 — only category list changed)
@guidance
def classify_ticket(lm, ticket_text, categories):
lm += f"""You are a customer support classifier.
Ticket: {ticket_text}
Classify this ticket into exactly one category.
Category: """
# JSON schema enum constraint — this is the problematic approach
lm += gen(
"category",
regex="|".join(categories) # regex alternation of all enum values
# In guidance 0.1.15, this compiles to a token path trie
# Each character position scans the full vocabulary for valid continuations
)
return lm
# Before schema update: classify_ticket(lm, ticket, PRODUCT_CATEGORIES_V1)
# After schema update: classify_ticket(lm, ticket, PRODUCT_CATEGORIES_V2)
# Same program, same hardware, different latency.
Why long enum values cause exponential latency growth
Guidance constrains LLM output by maintaining a state machine over the allowed-output grammar. At each token generation step, the guidance engine queries the state machine for the set of all tokens in the LLM’s vocabulary that are valid at the current position in the output. It applies this set as a mask to the LLM’s logit distribution before sampling, guaranteeing that only valid tokens can be selected.
When guidance uses a regex alternation (BILLING|RETURNS|TECHNICAL|...), it compiles this into a deterministic finite automaton (DFA). At each generation step, the guidance engine must compute which vocabulary tokens are accepted by the DFA at the current state. With llama.cpp’s BPE tokenizer and a 32,000-token vocabulary, this computation involves scanning the vocabulary to find tokens that are valid continuations of any active DFA state.
# How guidance token path computation scales with enum value length
# (simplified illustration of the mechanism)
# For each token generation step, guidance computes:
# valid_next_tokens = {tok for tok in vocabulary if dfa.accepts_prefix(current_state + tok)}
# The cost of dfa.accepts_prefix() depends on:
# 1. The number of active DFA states (proportional to number of enum values)
# 2. The length of the token string being evaluated
# 3. The number of possible completions at each position
# Example: generating "PROFESSIONAL_SERVICES" with llama.cpp BPE tokenizer
# llama.cpp Llama-3.1 vocabulary splits this string as:
# "PROFESS" | "IONAL" | "_SERV" | "ICES" (4 tokens)
# or
# "PRO" | "FESSION" | "AL_SERV" | "ICES" (4 tokens alternative)
# Multiple valid tokenizations exist for the same character sequence.
# At each generation position, guidance must find all vocabulary tokens that:
# (a) match the next character(s) of any in-progress enum value path
# (b) don't extend past any enum value's end
# With 15 enum values active simultaneously, this is 15 parallel path explorations.
# Before schema update (12 enum values, max 15 chars):
# Average token positions per value: ~3.1
# Total vocabulary scans: 12 values × ~3.1 positions × 32,000 tokens = 1.19M evaluations
# Per classification: ~0.8s CPU time
# After schema update (15 enum values, max 23 chars):
# 3 new values average 21.7 chars → ~5.8 token positions each
# Additional vocabulary scans: 3 × 5.8 × 32,000 = 556,800 evaluations
# Plus existing 12 values now compete with 3 more active DFA states:
# path intersection overhead grows with number of simultaneous active paths
# Total vocabulary scans: ~2.88M evaluations
# Per classification: ~14.3s CPU time
The critical insight is that this is a CPU-bound computation. Adding more servers (which the team tried) adds concurrency capacity but does not reduce the per-request computation time. Each server processes individual requests at 14 seconds each; four additional servers process four requests at 14 seconds each simultaneously. Throughput scales with server count, but the latency per request — and the SLA breach caused by waiting tickets — does not improve.
Discovery: profiling with cProfile
The retainer developer’s first step was to profile a single classification call to identify where the 14 seconds was being spent:
# Diagnostic: profile single classification call
import cProfile
import pstats
sample_ticket = ("Our enterprise customer is unable to activate their new "
"license keys after the Q3 contract renewal was processed.")
pr = cProfile.Profile()
pr.enable()
result = classify_ticket(lm, sample_ticket, PRODUCT_CATEGORIES_V2)
pr.disable()
stats = pstats.Stats(pr)
stats.sort_stats("cumulative")
stats.print_stats(15)
# Top 15 cumulative time entries (abridged):
# ncalls tottime cumtime filename:lineno(function)
# 1 0.001 14.312 classifier.py:28(classify_ticket)
# 1 0.000 14.311 guidance/models/llama_cpp.py:204(__call__)
# 847 0.312 14.205 guidance/models/llama_cpp.py:312(_get_logits)
# 847 13.891 13.891 guidance/_grammar.py:178(get_next_tokens)
# 23 0.002 0.008 guidance/_grammar.py:94(advance_state)
#
# KEY FINDING:
# guidance/_grammar.py:178(get_next_tokens) called 847 times total
# 13.891s / 847 calls = 16.4ms per call
# 847 calls = one call per token generation step for the 22-char output
# "ENTERPRISE_LICENSING" took 20 steps; "IMPLEMENTATION_SERVICES" would take ~23
# This is the constrained decoding token path trie scan
get_next_tokens() was consuming 13.9 of the 14.3 seconds. The retainer developer confirmed the hypothesis by running a micro-benchmark comparing individual enum values:
# Micro-benchmark: latency by enum value length
# Force the model to generate each specific enum value by providing a prefix prompt
# that uniquely identifies each category
import time
test_cases = [
("BILLING", "My invoice was charged twice for"),
("DOCUMENTATION", "The API reference page for the authentication endpoint"),
("FEATURE_REQUEST", "We would like to request a new capability"),
("PROFESSIONAL_SERVICES", "We need help configuring our enterprise implementation"),
("ENTERPRISE_LICENSING", "Our Q3 contract renewal volume tier needs to be"),
("IMPLEMENTATION_SERVICES", "The onboarding team needs assistance deploying"),
]
for expected_category, prompt in test_cases:
start = time.perf_counter()
result = classify_ticket(lm, prompt, PRODUCT_CATEGORIES_V2)
elapsed = time.perf_counter() - start
print(f"{expected_category:28s} ({len(expected_category):2d} chars): {elapsed:.2f}s")
# Output:
# BILLING ( 7 chars): 0.81s
# DOCUMENTATION (13 chars): 1.43s
# FEATURE_REQUEST (15 chars): 1.61s
# PROFESSIONAL_SERVICES (22 chars): 3.87s ← 4.8× longer than BILLING
# ENTERPRISE_LICENSING (20 chars): 3.41s
# IMPLEMENTATION_SERVICES (23 chars): 4.12s
#
# Per-character scaling roughly follows: latency ≈ 0.07s × n_chars²
# (quadratic not linear — additional active paths from longer values
# increase intersection cost for shorter values too)
The fix: guidance.select() with a pre-built Trie
guidance.select() is guidance’s purpose-built primitive for selecting from a fixed list of string options. Unlike a regex alternation compiled to a DFA, select() pre-builds a character-level Trie from the candidate list at program construction time. During generation, it navigates the Trie character-by-character, only performing vocabulary intersection at characters where multiple Trie paths diverge. For a list of 15 enum values with shared prefixes (no shared prefixes in this case), the number of ambiguous positions is at most the length of the longest common prefix pair — which for these product category names is 0. The vocabulary scan happens once at position 0 (to choose the first letter), and the Trie narrows the candidate set at each subsequent position based on which branches are still active.
# classifier.py — FIXED
# Fairbrook Classification Systems Ltd.
# guidance 0.1.15, Python 3.11
import guidance
from guidance import models, select
lm = models.LlamaCpp(
"models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
n_gpu_layers=0,
n_threads=48,
n_ctx=2048
)
PRODUCT_CATEGORIES_V2 = [
"BILLING", "RETURNS", "TECHNICAL", "SHIPPING", "ACCOUNT",
"INTEGRATION", "PERFORMANCE", "SECURITY", "COMPLIANCE", "ONBOARDING",
"FEATURE_REQUEST", "DOCUMENTATION",
"PROFESSIONAL_SERVICES", "ENTERPRISE_LICENSING", "IMPLEMENTATION_SERVICES",
]
# Sort by expected frequency (most common first) for faster early Trie termination
# Frequency data from September 2026 ticket log:
PRODUCT_CATEGORIES_SORTED = sorted(
PRODUCT_CATEGORIES_V2,
key=lambda cat: ticket_frequency.get(cat, 0),
reverse=True
)
# After sort: ['TECHNICAL', 'BILLING', 'INTEGRATION', 'ACCOUNT', 'RETURNS',
# 'SHIPPING', 'PERFORMANCE', 'SECURITY', 'ONBOARDING', 'COMPLIANCE',
# 'FEATURE_REQUEST', 'DOCUMENTATION', 'PROFESSIONAL_SERVICES',
# 'ENTERPRISE_LICENSING', 'IMPLEMENTATION_SERVICES']
@guidance
def classify_ticket(lm, ticket_text, categories):
lm += f"""You are a customer support classifier.
Ticket: {ticket_text}
Classify this ticket into exactly one category.
Category: """
# FIXED: use select() instead of regex alternation
# select() uses a pre-built character-level Trie
# O(n_chars × n_diverging_positions) instead of O(n_values × vocab_size × n_positions)
lm += select(categories, name="category")
return lm
# Benchmark after fix:
# BILLING ( 7 chars): 0.82s (unchanged)
# DOCUMENTATION (13 chars): 0.88s (was 1.43s)
# FEATURE_REQUEST (15 chars): 0.91s (was 1.61s)
# PROFESSIONAL_SERVICES (22 chars): 0.94s (was 3.87s)
# ENTERPRISE_LICENSING (20 chars): 0.93s (was 3.41s)
# IMPLEMENTATION_SERVICES (23 chars): 0.95s (was 4.12s)
# ← Near-flat latency across all enum value lengths
Validation
# Load test results after fix (500 requests, production hardware)
# Before fix (5 weeks September–October 2026):
# p50 single-request latency: 14.3s (was 0.8s before schema update)
# p95 single-request latency: 18.7s
# Throughput: 252 tickets/hour (was 1,250/hour)
# SLA compliance (2-hour FRT): 31% (was 94%)
# Servers in use: 6 (was 2; added 4 without improvement)
# Backlog at peak: 31,000 tickets
# After fix (guidance.select(), October 24 2026):
# p50 single-request latency: 0.91s (matches pre-update baseline)
# p95 single-request latency: 1.14s
# Throughput: 1,190 tickets/hour (matches pre-update baseline)
# SLA compliance (2-hour FRT): 96% (restored)
# Servers needed: 2 (4 extra servers decommissioned)
# Classification accuracy: 98.4% (unchanged — same model, same categories)
# Postmortem requirements added to schema change runbook:
# 1. Any enum value list change must include a latency benchmark on staging
# before production deployment (use the micro-benchmark script above).
# 2. If any single category's latency exceeds 2× the shortest category's latency,
# the change must be reviewed for guidance.select() vs regex impact.
# 3. guidance.select() is the default for all new enum-constrained fields.
# regex alternation is prohibited for enum lists with values over 12 characters.
The four additional servers added during the incident were decommissioned after the fix. The horizontal scaling had consumed $3,400/month in infrastructure cost over 5 weeks for no latency improvement. The retainer engagement cost 2.5 hours. The postmortem requirement — a latency benchmark before any schema change — was implemented as a CI check that runs the micro-benchmark script on every pull request that modifies the category list.
Total retainer engagement: 2.5 hours (0.5h triage: confirm latency before vs after September 10 commit; run cProfile on single classification call; identify get_next_tokens() as 87% of CPU time; confirm enum value change is the sole trigger; 0.5h root cause: run per-value micro-benchmark; confirm quadratic latency scaling with enum value length; explain BPE tokenizer DFA path intersection mechanism; confirm fix option via guidance docs: select() vs regex(); 1.0h fix: replace gen(regex=...) with select(); implement frequency-sorted category list; run 500-request load test; confirm p50 0.91s; confirm classification accuracy unchanged; 0.5h documentation: write schema change latency benchmark CI check; document guidance.select() policy in team runbook; run retrospective on horizontal scaling decision).
Related retainer posts
- DSPy developer on retainer — compiled ChainOfThought program loses few-shot demos after program.load() — production RAG performs same as uncompiled baseline for 3 months
- Pydantic AI developer on retainer — module-level asyncpg pool bound to application event loop — asyncio.run() in pytest creates new event loop — 100% CI test failure for 7 weeks
- vLLM developer on retainer — DCGM Exporter allocates 1.4 GB GPU memory after KV cache initialization — A100 OOM on 8 concurrent long-context requests for 9 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →