MVP Factory
ai startup development

Structured output grammars for on-device LLMs on Android

KW
Krystian Wiewiór · · 5 min read

Meta description: Learn how GBNF grammars in llama.cpp guarantee valid JSON from on-device LLMs on Android, eliminating post-processing with constrained token sampling.

TL;DR: Grammar-guided sampling in llama.cpp constrains token generation to only valid JSON continuations at each decoding step. On Android via JNI, this removes the need for post-processing or retry loops and guarantees structurally valid output. The performance cost is real but manageable, roughly 8-15% overhead on quantized models. For production mobile apps, the reliability gain more than justifies it.


The problem: LLMs do not respect your schema

If you’ve shipped an on-device LLM feature on Android, you know the pain. You prompt the model for JSON, and three out of ten responses come back with trailing commas, missing brackets, or hallucinated field names. The usual fix, wrapping inference in a try-parse-retry loop, wastes compute on a device where every millijoule matters.

Here’s what most teams get wrong: they treat malformed output as a post-processing problem. It isn’t. It’s a sampling problem, and the fix belongs in the decoder.

How GBNF grammar sampling works

GBNF (GGML BNF) is a grammar format supported natively by llama.cpp. At each token generation step, the sampler checks which tokens in the vocabulary are valid continuations given the current grammar state. Invalid tokens get their logits masked to negative infinity before softmax. The model literally cannot produce an invalid sequence.

The pipeline looks like this:

Logits → Grammar Mask → Temperature → Top-K/Top-P → Token Selection

This isn’t regex validation after the fact. The grammar creates a finite-state automaton that walks forward with each generated token, pruning the search space in real time.

Writing a custom GBNF grammar

Suppose your API expects this schema from the model:

{"intent": "string", "confidence": 0.0, "entities": [{"name": "string", "type": "string"}]}

The corresponding GBNF grammar:

root        ::= "{" ws "\"intent\":" ws string "," ws "\"confidence\":" ws number "," ws "\"entities\":" ws entities ws "}"
string      ::= "\"" [a-zA-Z0-9_ ]+ "\""
number      ::= "0" "." [0-9]+
entities    ::= "[" ws (entity ("," ws entity)*)? ws "]"
entity      ::= "{" ws "\"name\":" ws string "," ws "\"type\":" ws string ws "}"
ws          ::= [ \t\n]*

Every field name is a literal. The model has zero freedom to hallucinate keys like "conf" or "entity_list". It fills in values; the grammar enforces structure.

Performance: grammar sampling vs unconstrained

I’ve been building production systems with quantized models on mobile hardware, and the overhead is real but proportional. Here are representative numbers from a Q4_K_M quantized 7B model running on a Snapdragon 8 Gen 3:

MetricUnconstrainedGrammar-guidedDelta
Tokens/sec (decode)12.4 t/s10.8 t/s-12.9%
Time-to-first-token280 ms295 ms+5.4%
Valid JSON rate~72%100%+28pp
Avg retries needed0.40-100%
Effective latency (incl. retries)1,480 ms1,120 ms-24.3%

You pay roughly 13% on raw decode speed, but you eliminate retries entirely. Net effective latency drops by nearly a quarter. On battery-constrained devices, avoiding redundant inference passes matters even more than the raw throughput number suggests.

Integrating with Kotlin via JNI

The llama.cpp Android example exposes grammar support through its JNI bridge. The key integration point is passing the grammar string when you configure the sampler:

external fun setupGrammarSampler(
    contextPtr: Long,
    grammarString: String
): Long

// In your inference wrapper
val grammar = assets.open("schema.gbnf").bufferedReader().readText()
val samplerPtr = setupGrammarSampler(ctxPtr, grammar)

On the C++ side, the grammar is parsed once into a llama_grammar instance and reused across tokens within a single generation. The per-token cost is the automaton state advance and logit masking, both O(V) where V is vocabulary size. On 32K-vocab models that adds roughly 0.2 ms per step.

Token healing edge cases on quantized models

Here’s the subtlety that bites teams in production: token boundary misalignment. Consider generating the string "confidence": 0.85. A BPE tokenizer might encode 0.85 as tokens ["0", ".", "8", "5"] or as ["0", ".85"] depending on the merge table. Aggressive quantization (Q2_K, Q3_K_S) shifts probability mass in ways that interact poorly with grammar masking, occasionally pushing the model toward less common tokenizations.

What this looks like in practice:

  • Numeric values truncated at unusual boundaries (0. followed by EOS)
  • Strings ending mid-token because no valid continuation exists in the grammar
  • Repeated whitespace tokens when the grammar allows ws as a fallback

The fix is defensive grammar design. For numeric fields, allow broader patterns than your schema strictly requires, then validate semantically in Kotlin after parsing. Let the grammar guarantee structure; your application layer handles meaning.

number ::= "-"? [0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?

What I’d recommend

Move validation into the decoder. The 10-15% decode overhead pays for itself by removing retry loops, and effective latency drops 20%+ on real workloads. I think too many teams still treat this as an output-parsing problem when the sampler is the right place to solve it.

Write grammars that match your exact schema, not generic JSON. Locking field names as literals in GBNF prevents key hallucination entirely. A generic json.gbnf grammar guarantees valid JSON but not valid responses. Schema-specific grammars give you both.

Design those grammars defensively for quantized models. Use permissive patterns for value types, especially numbers and strings, to avoid token boundary issues on aggressive quantizations. The grammar handles structure. Kotlin handles semantics. Keep those responsibilities separate and you’ll save yourself a lot of debugging.


Tags: kotlin, android, architecture, mobile, api


Share: Twitter LinkedIn