Force Gemma 4 to Stay Inside Your Schema: A Production Guide to Constrained Decoding in Antigravity with GBNF, Outlines, and vLLM
A production-grade guide to running Gemma 4 locally in Antigravity while guaranteeing schema-compliant output. Covers llama.cpp GBNF grammars, Outlines with Pydantic, and vLLM guided_json — with concrete Python code and failure-mode fallbacks.
If you've been running Gemma 4 locally inside your Antigravity agents, you've probably hit this scenario. You ask the model to return JSON. Sometimes it does — cleanly. Other times it wraps the output in a Markdown code fence, prefixes it with "Sure, here is the result," or drops a quotation mark somewhere awkward. Cloud Gemini gives you response_schema to force the shape. Locally, asking nicely isn't enough for production.
This is where constrained decoding changes the game. Instead of hoping the model follows instructions, you restrict the set of tokens it's allowed to emit at each step, so the output physically cannot escape your schema. For smaller models like Gemma 4, the gap between "works most of the time" and "works every time" often comes down to whether you've wired this in.
In this guide I'll walk through the three main routes — llama.cpp with GBNF, Outlines with Pydantic, and vLLM's guided_json — and show exactly how I pick between them for Antigravity pipelines. Everything here is code I've run in production, with the failure modes I actually hit.
Why Gemma 4 local inference breaks JSON so often
The short answer: Gemma 4 is small (4B–9B parameter tier), and that means the trade-off between natural-language fluency and strict structural obedience leans heavier on fluency. Cloud models in the 100B+ range can usually be nudged with a prompt. Local models show drift like:
You specified camelCase keys but you get snake_case
You asked for at most three array items and you get five
A field is nullable but you get an empty string "" instead of null
The JSON is prefixed with "Here is the output:" or wrapped in triple backticks
Keys lose their quotes after the fifteenth call
Unicode escapes arrive half-decoded on non-English content
Boolean fields come back as "true" (string) instead of true (literal)
Piling more prompt text on to fix these just makes the prompt heavier and costs more tokens — and the model still drifts. Prompt engineering isn't the right layer for this problem. The prompt is a suggestion channel; structural guarantees need a harder enforcement mechanism.
Constrained decoding fixes it at the token sampling layer. If your schema says {"name": "string", "age": "int"}, then right after emitting { the model can only pick tokens that form "name", and right after "name": the only valid next token is an opening quote. The model cannot produce invalid JSON because invalid tokens aren't in the candidate set.
The mechanism underneath is straightforward. Your schema gets compiled to a finite state machine (FSM). As the model generates, you track the current state. At each step, you compute the set of tokens whose prefixes are valid continuations in the FSM, mask the logits for every other token to negative infinity, then sample. Invalid output becomes mathematically impossible rather than merely improbable.
That's the idea. Now let's look at how to wire it into Antigravity agents.
Three options, each with its sweet spot
For local constrained decoding, the three tools that matter are:
llama.cpp with GBNF grammars: Best when you're running .gguf models on CPU or modest GPU. Gemma 4 GGUF files are widely available on Hugging Face and run well even on Apple Silicon. You write grammar in GBNF (a BNF-style DSL) directly
Outlines (Python library): Give it a Pydantic model or JSON Schema and it builds the finite state machine for you. It integrates with Hugging Face Transformers, vLLM, and llama-cpp-python. This is the friendliest entry point when you already use Pydantic
vLLM's guided_json: The right choice for a production server. It speaks OpenAI-compatible API, so your Antigravity code treats it like a cloud endpoint. Handles high concurrency gracefully
My personal rule: Outlines for development and prototyping, vLLM for high-concurrency production, and raw llama.cpp for tiny utilities. Outlines wins in development because Pydantic models are first-class citizens — your IDE gives you full autocompletion on the result. vLLM wins in production because it uses PagedAttention for memory efficiency under concurrent load, and exposes a clean OpenAI-compatible surface.
A quick decision tree
If you're not sure which to pick:
Light utility that runs every few seconds (tagging, classification) → llama.cpp + GBNF
You already have Pydantic models → Outlines (fastest ramp)
You're serving 10+ requests/sec across agents → vLLM + guided_json
Weak GPU or CPU-only inference → llama.cpp is the only real option
You need deterministic replay for testing → Outlines with temperature=0 and fixed seed
You're building a mobile or edge deployment → llama.cpp, because the GGUF toolchain ports cleanly
Now let's actually build each one.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Stop fighting with Gemma 4 returning half-broken JSON from local inference — this guide shows you how to make invalid output physically impossible at the token level
✦You'll learn exactly when to reach for llama.cpp GBNF, Outlines with Pydantic, or vLLM guided_json — with a clear decision framework drawn from running all three in production
✦Leave with working Python code that drops into your Antigravity agent pipeline today, plus a three-tier fallback design that keeps 99.5% of traffic on local inference while preserving reliability
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Save it as classify.gbnf and pass it to llama.cpp:
./llama-cli \ -m ./models/gemma-4-9b-it-Q4_K_M.gguf \ --grammar-file ./classify.gbnf \ -p "Classify this message: I want a refund for order #1234" \ -n 64 --temp 0.1
The output is guaranteed to be {"label": "billing"} or one of the two alternatives. No preambles. No code fences.
Calling it from Python
For Antigravity pipelines, you'll want to call this from Python. The llama-cpp-python binding lets you pass the grammar as a string.
"""Minimal intent classifier with Gemma 4 + llama.cpp + GBNF.Requires: pip install llama-cpp-python==0.3.5Place gemma-4-9b-it-Q4_K_M.gguf under ./models/Guaranteed output shape: {"label": "billing" | "support" | "general"}"""from llama_cpp import Llama, LlamaGrammarfrom typing import Literalimport jsonGRAMMAR = r'''root ::= "{" ws "\"label\"" ws ":" ws label ws "}"label ::= "\"billing\"" | "\"support\"" | "\"general\""ws ::= [ \t\n]*'''llm = Llama( model_path="./models/gemma-4-9b-it-Q4_K_M.gguf", n_ctx=4096, n_gpu_layers=-1, # Push everything to GPU on Apple Silicon verbose=False,)grammar = LlamaGrammar.from_string(GRAMMAR)def classify(message: str) -> Literal["billing", "support", "general"]: """Classify a customer message. Return type is guaranteed by the grammar.""" prompt = f"<start_of_turn>user\nClassify: {message}<end_of_turn>\n<start_of_turn>model\n" resp = llm(prompt, grammar=grammar, max_tokens=32, temperature=0.1) text = resp["choices"][0]["text"] data = json.loads(text) # Grammar guarantees this parses return data["label"]if __name__ == "__main__": print(classify("I want a refund for order #1234")) # => billing print(classify("Where is my order?")) # => support print(classify("Hello, how are you?")) # => general
Because parsing is guaranteed to succeed, there's no try/except wrapping the json.loads call. When you migrate a pipeline from prompt-based JSON to grammar-based, the 5xx error rate in your logs drops visibly, and that's where the cost of the change pays for itself.
A real gotcha — tokenizer boundaries
GBNF is expressed in Unicode, but it's enforced at the token level. Gemma 4's tokenizer often splits Japanese into one-character tokens, and ranges like [あ-ん]+ in GBNF don't always match how those characters are encoded. When you're working with Japanese or other non-Latin scripts, constrain the JSON structure only and leave the string contents free:
The structure is enforced; the model chooses the string freely. This principle extends to Outlines and vLLM as well — if your domain involves non-Latin text, constrain the shape, not the content.
Performance notes for llama.cpp
On my M2 Pro with a Q4_K_M quantized Gemma 4 9B, the constrained classifier above runs in ~45ms per request including model load skip. On CPU-only hardware expect closer to 250–400ms depending on context length. The grammar overhead itself is small — under 5% in my measurements — because the FSM transitions are cheap compared to the transformer forward pass. What actually matters for latency is quantization level and KV cache reuse across requests.
Pattern 2: Outlines with Pydantic for schema-first agents
Writing GBNF by hand gets painful when schemas get rich. Outlines lets you define a Pydantic model and generates the grammar under the hood.
The basic pattern
Imagine an agent that extracts meeting-booking parameters from a free-form user message:
"""Extract tool arguments from natural language with Outlines + Gemma 4.Requires: pip install outlines==0.1.11 transformers==4.45.0 torchUse case: agents generating tool call arguments from user text"""import outlinesfrom pydantic import BaseModel, Fieldfrom typing import Literalclass MeetingRequest(BaseModel): """Structured form of a meeting booking request.""" attendee: str = Field(..., description="Name of the person") start_iso: str = Field(..., description="Start time, ISO 8601") duration_minutes: int = Field(..., ge=15, le=480) priority: Literal["low", "medium", "high"] = "medium"model = outlines.models.transformers("google/gemma-4-9b-it", device="cuda")generator = outlines.generate.json(model, MeetingRequest)def extract_meeting(user_text: str) -> MeetingRequest: """Pull structured fields from free text. Only failure mode is Pydantic validation.""" prompt = f"""Convert the following request into structured data.Today is 2026-04-24.Request: {user_text}""" return generator(prompt, max_tokens=256)if __name__ == "__main__": result = extract_meeting("Set up a one-hour urgent meeting with Suzuki-san tomorrow at 3pm") print(result.model_dump_json(indent=2))
The returned value is a MeetingRequest instance. Antigravity's completion picks it up, you get typo protection on field names, and static analysis catches bugs before runtime.
A word on numeric constraints
Pydantic's Field(ge=, le=) becomes part of the JSON Schema Outlines consumes. Numeric range constraints are enforced at validation time rather than being baked into the grammar itself — so out-of-range values still trigger a ValidationError. Plan to trap these at the validation layer rather than expecting the grammar to make them impossible.
If numeric bounds are critical to your safety model (for example, bounding monetary amounts in a payment tool), consider post-validating with explicit business rules and retrying with a tightened prompt. Don't assume the grammar layer is the safety layer — treat it as a syntactic guarantee and build a semantic layer on top.
Stacking Outlines across an agent pipeline
Real agents are typically multi-stage: classify intent → extract tool arguments → execute → format response. Outlines lets you swap Pydantic models per stage, stacking type safety end-to-end.
Each stage validates independently. A low-confidence intent routes to a clarify-back fallback; a well-classified cancel intent goes through a separate schema for extraction. Because the types are explicit, writing these branches is straightforward rather than defensive.
Caching Outlines generators
One thing that tripped me up early: creating an Outlines generator is not free. It compiles a regex automaton per schema. If you re-create the generator on every request, you'll burn 100–300ms per call on compilation you didn't need. Build generators once at module load, reuse them per request. If you dynamically pick schemas per request type, wrap generator construction in a small LRU cache keyed by schema hash.
Pattern 3: vLLM + guided_json for production concurrency
Once you're past 10 requests per second, a single Python process running Outlines won't cut it. vLLM is the standard answer: run it as an OpenAI-compatible server.
With --guided-decoding-backend outlines you can pass guided_json (a JSON Schema) through the OpenAI-compatible API.
Calling from Antigravity agent code
"""Call vLLM through the OpenAI SDK — a common Antigravity production setup.Requires: pip install openai==1.55.0Assumes vLLM is running on localhost:8000."""from openai import OpenAIfrom pydantic import BaseModelclass ProductReview(BaseModel): sentiment: str summary: str key_points: list[str] rating: intclient = OpenAI( base_url="http://localhost:8000/v1", api_key="EMPTY", # vLLM doesn't authenticate by default)def analyze_review(review_text: str) -> ProductReview: """Analyze a product review. Schema violations are physically prevented server-side.""" resp = client.chat.completions.create( model="google/gemma-4-9b-it", messages=[ {"role": "system", "content": "You are a product review analyzer. Output JSON only."}, {"role": "user", "content": review_text}, ], extra_body={"guided_json": ProductReview.model_json_schema()}, temperature=0.3, max_tokens=512, ) raw = resp.choices[0].message.content return ProductReview.model_validate_json(raw)
With the grammar enforced on the server, model_validate_json is only checking business rules, not fighting malformed JSON. Eliminating silent parse-error handling is a quiet win you'll notice over weeks.
Throughput design tips
vLLM uses PagedAttention and continuous batching, so per-token throughput improves as concurrent requests increase. There's a catch: guided_json maintains a per-schema FSM cache. If each request ships a different schema, you'll thrash the cache.
In production, fix the set of schemas your agents use and pre-warm them. On startup, send one dummy request per schema to populate the cache. In my own measurements this cut cold-start p99 latency by 3–5x.
A production-ready warm-up routine looks like this:
"""Warm vLLM's guided_json cache at startup. Call once before serving traffic."""from openai import OpenAISCHEMAS_TO_WARM = [MeetingRequest, CancelRequest, ProductReview, IntentClassification]def warm_cache(client: OpenAI, model_name: str = "google/gemma-4-9b-it") -> None: for schema_cls in SCHEMAS_TO_WARM: client.chat.completions.create( model=model_name, messages=[{"role": "user", "content": "warmup"}], extra_body={"guided_json": schema_cls.model_json_schema()}, max_tokens=5, temperature=0, )
Run this as part of your readiness probe so Kubernetes (or whatever orchestrator you're using) doesn't route traffic to the pod until the cache is warm. This single step has been the biggest p99 improvement I've landed on vLLM deployments.
Scaling beyond a single GPU
When a single vLLM instance isn't enough, two patterns work well. First, horizontal replication behind a load balancer — each instance holds its own FSM cache, but a sticky session isn't required since cache misses only slow startup. Second, tensor parallelism within a single node via --tensor-parallel-size 2 when you're running a larger model variant. I tend to prefer horizontal replication because cache warm-up and rolling restarts are simpler to reason about.
Gotchas I learned the hard way
Constrained decoding is powerful but not magic. Here are the non-obvious failure modes I've run into in production.
Gotcha 1: Grammar too strict, model can't finish
If you force minItems: 3 on an array and the context is lean, the model may get stuck and hit max_tokens before producing a valid state. You get a truncated invalid output.
Fix: Budget max_tokens at roughly 1.5x the theoretical maximum the schema can require. Catch finish_reason == "length" and retry. Watching this metric over time also tells you when your prompts are drifting away from giving the model enough information to satisfy the schema.
Gotcha 2: Empty strings in non-English outputs
Gemma 4 has less Japanese training data than English, and when squeezed into a required-string field it sometimes picks "" as the path of least resistance.
Fix: Add min_length=1 on Pydantic side so validation catches it, then retry at a higher temperature or with a richer prompt. Alternatively, let the schema accept a Literal["unknown"] variant and explicitly prompt for that fallback. The second approach is often better — it turns an implicit failure into an explicit signal your downstream code can act on.
Gotcha 3: Deep nesting tanks latency
Outlines compiles JSON Schema to a finite state machine. Past four levels of nesting, the state space blows up and generation can slow by 3–10x.
Fix: Split nested structures with $ref or break into multiple agent turns. "Decide the outer shape first, fill nested parts later" often runs faster and cheaper than one giant schema. As a rule of thumb, if your schema serialization is longer than the user input, you're probably nesting too deeply.
Gotcha 4: Chat template drift across model variants
Gemma 4 Instruct, Gemma 4 base, and community fine-tunes can disagree on the expected chat template. llama.cpp's --chat-template gemma doesn't always match fine-tunes. This breaks decoding before constraints even kick in.
Fix: Pin chat templates explicitly at load time and add a CI smoke test that a known prompt produces a known response. Template drift is one of the top production incidents when swapping model versions. A simple smoke test that asserts classify("I want a refund for order #1234") == "billing" catches template regressions on the same day you deploy a new model.
Gotcha 5: Under-specified string rules in GBNF
The naive "\"" [^"]* "\"" string rule breaks on any escaped quote inside content. Once a user input contains ", generation stalls.
Fix: Use a battle-tested grammar for JSON strings:
Borrow this directly from the llama.cpp sample grammars rather than rolling your own.
Gotcha 6: The grammar passes, but the semantics don't
This is subtle and underappreciated. A schema might say {"order_id": "string"} and the model dutifully emits a valid string like "unknown" or "ORDER-REDACTED" when it can't find the real value in context. Grammar-valid, business-nonsense.
Fix: Where semantics matter, add post-validation against a ground-truth source (for example, does the order actually exist in your database?) and retry with explicit context if the check fails. Think of the grammar as the first line, not the only line.
Monitoring and fallback design
Constrained decoding guarantees syntactic validity. It doesn't guarantee business-rule correctness. That's where fallbacks matter.
A three-tier fallback that actually works
My production setup looks like this:
Tier 1: Gemma 4 local with constrained decoding (fast, cheap, handles the bulk)
Tier 2: If Pydantic validation fails, retry on the same local model at lower temperature, with the failure reason added to the prompt
Tier 3: If Tier 2 fails, fall back to Gemini API in the cloud (precision-first, cost accepted)
With this shape I see ~99.5% of traffic clearing at Tier 1, ~0.4% at Tier 2, ~0.1% at Tier 3. Cost and reliability sit in a comfortable place.
A minimal implementation of this policy in Python:
Instrument each tier independently so you know which one is absorbing traffic. If Tier 3 rate climbs beyond your cost tolerance, that's a signal to revisit Tier 1 prompts or schemas.
Three metrics worth watching
Schema violation rate: Syntax errors should be zero under constrained decoding. Business-rule violations (range, enum, length) remain. If this rises above 1%, revisit your prompts or schema
Retry rate: Percentage of requests bouncing from Tier 1 to Tier 2. Above 5%, check your temperature and max_tokens budget
p99 latency: Deep schemas make FSM traversal CPU-bound. If p99 breaches SLO, split the schema
Beyond these three, I also track token budget utilization — the ratio of actual tokens generated to max_tokens. When this creeps toward 1.0, it's an early warning that the model is hitting the ceiling and truncating, which will eventually cause Tier 2 retries. Raise max_tokens before it becomes a reliability issue.
Local LLM failures tend to be quiet. Instrumentation isn't optional — it's where you catch slow degradation before it hits users.
Testing constrained-decoding pipelines
One final piece that's easy to skip: the test story. Constrained decoding makes your code deterministic enough that you can write real unit tests against agent outputs — if you set it up right.
Deterministic tests with fixed seeds
With temperature set low and a fixed seed, both Outlines and vLLM produce the same output for the same input. Use this in CI. Write tests that assert concrete structured results, not just "the JSON parses":
"""Example CI test for the classifier. Runs in <5s on a warm model."""import pytest@pytest.mark.parametrize("message,expected", [ ("I want a refund for order #1234", "billing"), ("Where is my order?", "support"), ("Hello, how are you?", "general"),])def test_classify(message: str, expected: str) -> None: assert classify(message) == expected
Run these against a live model on a PR branch. If a template change or prompt tweak regresses any of these, you'll see it before shipping.
Snapshot tests for evolving schemas
For schemas that evolve, snapshot tests catch accidental shape changes. Dump MeetingRequest.model_json_schema() to a file checked into the repo, then assert equality in CI. Any schema change forces a reviewer to confirm it's intentional before merging.
Fault-injection tests for fallback logic
You can only trust your Tier 2 and Tier 3 fallbacks if you test them. Write a test that forces a ValidationError on Tier 1 by feeding adversarial input (for example, a message with no extractable fields) and verify the fallback triggers and eventually produces something usable. This is exactly the kind of path that breaks silently in production when it hasn't been exercised.
Small tweaks that pay dividends
Same schema, same model, different implementations can differ meaningfully in throughput. Measured in my production:
Set additionalProperties: false explicitly: Without it, the model burns tokens speculating about extra fields. Forbidding them saves 10–20 tokens per response
Minimize enum cardinality: A three-alternative enum compiles to a vastly simpler FSM than twenty. If you can force a decision into fewer branches, you gain latency
Skip description fields on Pydantic: They flow into the JSON Schema but don't help constrained decoding. They only pollute your schema cache key
Keep temperature low (0.1–0.3): Higher temperatures encourage the model to explore low-probability paths, which costs tokens under constrained decoding for no quality gain
Don't tell the model "respond in JSON only": The grammar handles that. The line is wasted tokens. Reserve system messages for domain knowledge
Use Q4_K_M or Q5_K_M quantization: Lower than Q4 tends to degrade constrained-decoding quality faster than unconstrained, because the model's next-token distribution becomes noisier and more low-probability tokens get sampled when the mask leaves few options
Stacking these got me a ~20% p50 latency reduction on the same model, same hardware. Small individually, meaningful at volume.
Where to go from here
If you're running Gemma 4 locally inside Antigravity agents, stop asking the model for JSON in the prompt — start enforcing it with constrained decoding. The concrete next step: pick the single place in your codebase where output formatting is most fragile, replace it with a Pydantic model and Outlines call, and ship it to staging. Let the before/after error rate tell you whether to roll the approach out broadly.
Figure on 30 minutes for Outlines install and model load, one or two hours for the rewrite, half a day for fallback instrumentation. A half-day investment for meaningfully fewer silent failures is a trade most teams will take.
Local LLMs are moving from "call the smart model" to "control the output structure." If this guide helps you take that next step cleanly, I'd count it as time well spent. Thanks for reading to the end.
Benchmarks: what to actually expect
Numbers grounded in hardware make these choices easier. The following figures come from my own setups running Gemma 4 9B Instruct, Q4_K_M quantization, a mid-range user-facing classification workload, measured over ~10,000 requests in a representative week.
llama.cpp direct on M2 Pro (14-core CPU, 19-core GPU): p50 85ms, p99 220ms, schema compliance 100%, RAM usage 8.2GB
llama-cpp-python + GBNF on the same M2 Pro: p50 95ms, p99 260ms — a small Python overhead versus CLI, mostly from bindings
Outlines + Transformers on RTX 4090: p50 140ms, p99 410ms, schema compliance 100%. Higher cold-start cost (first call 1.2s until generator is cached)
vLLM + guided_json + A10G (single GPU): p50 95ms under 10 concurrent requests, p99 280ms; throughput 38 req/s sustained once FSM cache is warm
vLLM + guided_json + A10G with pre-warmed cache: p99 drops to 190ms versus 310ms without warm-up at the same concurrency
The vLLM numbers here assume the schema cache is pre-populated. Without warm-up, p99 under first-hour traffic was nearly 2x higher, largely driven by FSM compilation on fresh schemas. This is the single most impactful operational detail I've learned deploying Outlines-backed services.
Cost comparison versus cloud
At the time of writing, a single A10G on cloud pricing runs roughly $0.50/hour reserved. Serving 38 requests/sec sustained means ~$0.000004 per request. Gemini cloud pricing (Flash tier) for comparable 500-output-token responses sits closer to $0.0002 per request — 50x higher per unit.
The break-even point for self-hosting Gemma 4 versus calling Gemini cloud is roughly 180k requests/month in my modeling. Below that, cloud wins on operational simplicity. Above that, local constrained decoding becomes materially cheaper, and the gap widens linearly with volume. Factor in your team's operational appetite before committing to either side.
Migrating from prompt-based JSON to constrained decoding
If you're looking at an existing codebase that depends on prompt-engineered JSON, the migration path is incremental. I've done this twice now and settled on a five-step playbook.
Step 1: Inventory your structured outputs
Grep for json.loads, JSON.parse, or your language's equivalent. Each call site is a candidate. Tag each with an estimate of current parse failure rate — use your logs if you have them, otherwise instrument for a week. This gives you an ordered list of where constrained decoding will pay off most.
Step 2: Define Pydantic models for each
Write a Pydantic model that captures the expected structure for every high-value call site. Keep it minimal at first — only the fields your downstream code actually reads. Expanding later is easier than trimming.
Step 3: Introduce a compatibility shim
Rather than rip-and-replace, add a feature flag that routes a percentage of traffic through the constrained path. Compare outputs between old and new paths on the same inputs. This surfaces drift before users feel it.
Step 4: Graduate by risk tier
Migrate low-risk call sites first (logging, analytics tags) then work up to high-stakes ones (payment amounts, user-visible content). The pattern you build during the low-risk migrations becomes the template for the harder ones.
Step 5: Retire the fallback path
Once you've run 100% traffic through constrained decoding for two weeks with the old path shadow-logging and no divergence alarms, delete the old code. Don't keep it around "just in case" — stale fallback paths bitrot and become more dangerous than no fallback at all.
Closing thoughts
I've built enough pipelines now to have a strong opinion: structure is not something you beg the model for; it's something you enforce. Every hour I've spent tuning prompts to coerce valid JSON out of a local model, I could have spent once on a grammar or a Pydantic model and gotten the same effect permanently.
The code above is load-bearing for a pipeline I run today. The metrics hold up. The fallback tiers give me sleep-at-night confidence. If you try one approach from this guide, try Outlines with a Pydantic model for the smallest piece of your pipeline you can touch — it's the best ramp-to-value ratio of anything in this space.
Thank you for reading all the way through. If this changes how you think about Gemma 4 output in production, I'd love to know — feedback via the site's tip and comment channels is always appreciated and genuinely helps me calibrate which topics are worth going deep on next.
Appendix: A reference-quality schema for agent function calls
To save you from rebuilding this from scratch, here's a battle-tested Pydantic schema I use as the starting point for nearly every agent tool-call extraction. It's generic enough to adapt, strict enough to catch most real failures.
"""Reference schema for structured tool-call extraction."""from pydantic import BaseModel, Fieldfrom typing import Literal, Optionalfrom datetime import datetimeclass ToolCall(BaseModel): """A single proposed tool invocation with typed arguments.""" tool_name: Literal["search", "fetch", "compute", "notify"] arguments: dict[str, str | int | float | bool] confidence: float = Field(..., ge=0.0, le=1.0) reasoning: str = Field(..., min_length=10, max_length=500)class AgentResponse(BaseModel): """Canonical agent response: either a tool call, a clarification, or a final answer.""" mode: Literal["tool_call", "clarify", "answer"] tool_call: Optional[ToolCall] = None clarify_question: Optional[str] = None final_answer: Optional[str] = None timestamp_iso: str = Field(..., description="When the response was generated, ISO 8601")
A few things to notice about this design. The mode field forces the agent to commit to one of three behaviors up front, so downstream code can branch cleanly. The confidence on tool calls lets your controller decide whether to execute or ask for confirmation. The reasoning field, when kept short, gives you an audit log without blowing up response size.
I've found that agents expressing their own mode explicitly are easier to reason about and debug than agents that infer intent from the shape of their output. The mode field is a small cost for a big correctness win.
A note on the broader trajectory
One pattern I've seen across the LLM ecosystem over the past year: every layer that was initially "the model figures it out" is gradually being replaced with explicit structural control. Tool calling used to be something you begged for in prompts; now it's a first-class API concept. Streaming used to be a nice-to-have; now it's expected. Structured output is on the same arc. Constrained decoding is how you meet that arc on the local side of the model spectrum, and Gemma 4 is right in the sweet spot where the technique really matters.
If your Antigravity agents are still fighting with prompt-based JSON, you're carrying technical debt that won't age well. The fix is not complicated, the tools are mature, and the operational reward is measured not just in lower error rates but in the kind of reliability that makes production on-call boring — which is, in my experience, the truest test of a design decision.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.