The Gemini Decisions API: Architecting a Deterministic Decision Service on a Probabilistic Model
A Hacker News thread argued about whether an LLM belongs on the critical path of a business decision. This is the production reference architecture that settles the argument: schema-bound structured output, bounded tool loops, deterministic policy gates, and a latency budget you can defend in a design review.
AI Generated with Qwen 3.0 via CometAPIThe Gemini Decisions API: Architecting a Deterministic Decision Service on a Probabilistic Model
There is a familiar arc to model-launch threads on Hacker News: a terse title, a benchmark chart, and four hundred comments arguing about whether an LLM belongs on the critical path of a business decision. The Gemini Decisions API discussion followed that arc precisely. This article is not a hot take on the thread — it is the reference architecture your team needs before you ship anything.
One clarification before we start, because it matters for procurement and for your architecture review: there is no standalone GA product named the "Gemini Decisions API." What the thread described — and what mature teams are actually running in production — is a decision service composed from documented Gemini primitives: structured outputs, function calling, context caching, and the Batch API — wrapped in a deterministic HTTP contract that your risk, compliance, and SRE teams can reason about.
Why a Decision API Is a Different Contract Than a Chat API
A chat endpoint promises plausible continuation. A decision endpoint promises a typed, auditable, reproducible-enough verdict with a bounded failure mode. That difference drives every architectural choice downstream:
- Output is a schema, not prose. The model is the last step of a pipeline, not the interface.
- Failure is a first-class state.
escalateis a valid, expected outcome — not an exception. - Reproducibility is a requirement. Temperature 0, fixed seed, pinned model version, recorded prompt hash.
- Latency is a budget, not a metric. A decision service usually sits behind a synchronous caller with a hard 2–3s p95.
Treat the LLM as a proposal engine. The service around it decides.
The Reference Architecture: Five Layers
Client ──► API Gateway (auth, idempotency, quota)
│
▼
[1] Decision Contract (JSON Schema / Pydantic / Zod)
│
▼
[2] Context Assembler (policy pack + record + retrieval)
│
▼
[3] Gemini Inference (structured output + bounded tools)
│
▼
[4] Deterministic Policy Gate (confidence, missing fields, rules)
│
▼
[5] Persistence, Trace, Eval Sampling ──► Audit Store / OTelLayer 1 — The Decision Contract Is the API
Everything else is an implementation detail. Model the outcome as a closed enum and force evidence to be cited. Zod and Pydantic both work; the point is that the schema is versioned, reviewed, and tested in CI.
# decisions/schema.py
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class Evidence(BaseModel):
source_id: str = Field(description="Stable ID of a policy clause or source record.")
quote: str = Field(max_length=280, description="Verbatim span from that source.")
polarity: Literal["supports", "contradicts"]
class Decision(BaseModel):
"""Wire contract for POST /v1/decisions (schema v3)."""
outcome: Literal["approve", "escalate", "deny"]
confidence: float = Field(ge=0.0, le=1.0)
rationale: str = Field(min_length=20, max_length=1200)
evidence: list[Evidence] = Field(min_length=1, max_length=8)
missing_fields: list[str] = Field(default_factory=list)
requires_human: bool = False
@model_validator(mode="after")
def _policy_gate(self) -> "Decision":
# Deterministic post-processing. The model proposes; the service disposes.
if self.confidence < 0.72 or self.missing_fields:
self.requires_human = True
if self.outcome == "deny" and not any(
e.polarity == "supports" for e in self.evidence
):
self.outcome = "escalate"
self.requires_human = True
return selfNote the validator: it never lets a low-confidence denial escape without human review. That logic belongs in code, not in a prompt.
Layer 2 & 3 — Bounded Inference With Structured Output and Tools
The inference layer assembles context, calls Gemini with response_schema + response_mime_type, and retries only on genuinely transient upstream failures.
# decisions/engine.py
import json
import logging
from google import genai
from google.genai import errors as genai_errors
from google.genai import types
from tenacity import (
retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter,
)
from .schema import Decision
log = logging.getLogger("decisions")
client = genai.Client()
MODEL = "gemini-2.5-flash" # pin explicitly; never use a floating alias in prod
SYSTEM = """You are a credit-limit review engine.
Rules:
1. Every assertion in `rationale` must map to an item in `evidence`.
2. `source_id` must come from the supplied policy pack. Never invent IDs.
3. If a required field is absent, add it to `missing_fields` and set outcome='escalate'.
4. Prefer 'escalate' over guessing. A wrong 'approve' costs 40x a manual review.
"""
RETRYABLE = {429, 500, 502, 503, 504}
class RetryableUpstream(Exception):
"""Normalized transient error from the inference provider."""
@retry(
retry=retry_if_exception_type(RetryableUpstream),
wait=wait_exponential_jitter(initial=0.4, max=8.0),
stop=stop_after_attempt(4),
reraise=True,
)
def decide(payload: dict, request_id: str) -> Decision:
try:
resp = client.models.generate_content(
model=MODEL,
contents=render(payload),
config=types.GenerateContentConfig(
system_instruction=SYSTEM,
response_mime_type="application/json",
response_schema=Decision,
temperature=0.0,
seed=1337,
max_output_tokens=2048,
thinking_config=types.ThinkingConfig(thinking_budget=0),
),
)
except genai_errors.APIError as exc:
if exc.code in RETRYABLE:
raise RetryableUpstream(f"upstream {exc.code}") from exc
log.error("non_retryable_upstream", extra={"request_id": request_id,
"code": exc.code})
raise
decision = resp.parsed # already a validated Decision instance
if decision is None:
raise RetryableUpstream(f"unparseable decision for {request_id}")
log.info("decision_emitted", extra={
"request_id": request_id,
"outcome": decision.outcome,
"confidence": decision.confidence,
"input_tokens": resp.usage_metadata.prompt_token_count,
"output_tokens": resp.usage_metadata.candidates_token_count,
})
return decisionWhen the decision legitimately needs lookups, bound the tool loop. Unbounded agentic loops are the number-one cause of p99 explosions in these systems.
HANDLERS = {"lookup_policy": lookup_policy, "fetch_account": fetch_account}
def decide_with_tools(payload: dict, max_turns: int = 4) -> Decision:
config = types.GenerateContentConfig(
system_instruction=SYSTEM,
tools=[lookup_policy, fetch_account],
response_mime_type="application/json",
response_schema=Decision,
temperature=0.0,
)
contents = [types.Content(role="user", parts=[types.Part(text=render(payload))])]
for _ in range(max_turns):
resp = client.models.generate_content(
model=MODEL, contents=contents, config=config
)
calls = resp.function_calls or []
if not calls:
if resp.parsed is None:
raise RetryableUpstream("final turn was not a valid Decision")
return resp.parsed
contents.append(resp.candidates[0].content)
parts = []
for call in calls:
handler = HANDLERS.get(call.name or "")
try:
if handler is None:
raise KeyError(f"unknown_tool:{call.name}")
result = {"result": handler(**(call.args or {}))}
except Exception as exc: # tool errors are data, not crashes
result = {"error": f"{type(exc).__name__}: {exc}"}
parts.append(types.Part.from_function_response(
name=call.name, response=result))
# Gemini expects function responses delivered in a user-role turn.
contents.append(types.Content(role="user", parts=parts))
raise TimeoutError("tool loop exhausted")Layer 4 — Caching and Batch Are Cost Architecture
Two primitives change the unit economics of a decision service:
- Context caching. Policy packs are large and stable. Cache the prefix (system instruction + policy corpus) and pay a reduced rate on reuse. This is where 70–90% of input tokens usually live.
- Batch mode. Nightly re-scoring, backfills, and eval suites should never touch the synchronous path. Batch is dramatically cheaper and has no p95 obligation.
Layer 5 — Guardrail: Idempotency, Traces, and Eval Sampling
- Require an
Idempotency-Keyheader; decisions are money. Retries from a flaky client must not double-approve. - Emit one OpenTelemetry span per decision with prompt hash, model version, token counts, outcome, and gate actions.
- Sample 1–5% of decisions for offline grading against a labelled golden set, and fail CI when agreement drops more than 2 points.
// decisions/client.ts — typed consumer side
import { z } from "zod";
const Evidence = z.object({
sourceId: z.string(),
quote: z.string().max(280),
polarity: z.enum(["supports", "contradicts"]),
});
export const Decision = z.object({
outcome: z.enum(["approve", "escalate", "deny"]),
confidence: z.number().min(0).max(1),
rationale: z.string().min(20).max(1200),
evidence: z.array(Evidence).min(1).max(8),
missingFields: z.array(z.string()),
requiresHuman: z.boolean(),
});
export type Decision = z.infer<typeof Decision>;
export async function requestDecision(
input: unknown,
signal: AbortSignal,
): Promise<Decision> {
const res = await fetch(`${BASE}/v1/decisions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
"idempotency-key": crypto.randomUUID(),
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
// RFC 9457 problem+json from the gateway
const problem = await res.json().catch(() => ({}));
throw new Error(`decision_failed:${res.status}:${problem.title ?? "unknown"}`);
}
const parsed = Decision.safeParse(await res.json());
if (!parsed.success) {
throw new Error(`contract_violation: ${parsed.error.message}`);
}
return parsed.data;
}Latency and Cost Math You Can Defend
Illustrative budget for one synchronous decision (verify against your own traces):
| Stage | p50 | p95 | Control |
|---|---|---|---|
| Context assembly + retrieval | 40 ms | 120 ms | Precomputed packs, warm cache |
| Gemini time-to-first-token | 380 ms | 900 ms | thinking_budget=0, Flash-tier model |
| Decode (~400 output tokens) | 600 ms | 1100 ms | Cap max_output_tokens |
| Schema validation + policy gate | 3 ms | 9 ms | Pure local CPU |
| Total | ~1.0 s | ~2.1 s | Hard client timeout at 3 s |
Cost is a linear function you should compute explicitly rather than guess at:
cost_per_1k = 1000 * (
uncached_in / 1e6 * P_in
+ cached_in / 1e6 * P_cached
+ output_tok / 1e6 * P_out
)With a 3,200-token prompt of which 2,800 are cached, 400 output tokens, and current published rates from the pricing page, most teams land in the sub-cent-per-decision range — cheap enough that the human review step, not the model, dominates the cost model. Optimize the escalation rate before you optimize tokens.
Production Pitfalls Checklist
- Never use a floating model alias. Pin the version string; run a shadow eval before bumping.
- Do not retry on 4xx. Retrying a malformed request turns a fast failure into a quota incident.
- Validate the parsed object, not just the JSON.
response_schemaconstrains generation; your gate still enforces business rules. - Cap tool turns. Four is usually generous. Log exhaustion as a distinct error class.
- Persist the prompt hash. Without it, "why did this change?" is unanswerable during an incident.
- Make `escalate` cheap. Route to a queue with SLA, not to a support inbox.
- Separate the eval set from the training examples you put in the system instruction — otherwise your agreement metric is circular.
When Not to Build One
If the decision is expressible as a deterministic rule (a threshold, a lookup, a rate table), ship the rule. An LLM decision layer earns its place only when the inputs are unstructured, the rule set is genuinely too large or too fast-moving to enumerate, and you have a human review path for the tail. If any of those three is missing, you are buying latency and nondeterminism you cannot amortize.
Sources & Technical References
- Google AI for Developers — Structured output (response schemas, JSON mode): https://ai.google.dev/gemini-api/docs/structured-output
- Google AI for Developers — Function calling (tool declarations, function responses): https://ai.google.dev/gemini-api/docs/function-calling
- Google AI for Developers — Context caching: https://ai.google.dev/gemini-api/docs/caching
- Google AI for Developers — Batch mode: https://ai.google.dev/gemini-api/docs/batch-mode
- Google AI for Developers — Gemini models (versions, context windows, thinking budgets): https://ai.google.dev/gemini-api/docs/models
- Google AI for Developers — Pricing: https://ai.google.dev/gemini-api/docs/pricing
- Google AI for Developers — Safety guidance and settings: https://ai.google.dev/gemini-api/docs/safety-guidance
- Vertex AI — Generative AI on Vertex AI: controlled generation: https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output
- JSON Schema Specification, Draft 2020-12 (Core): https://json-schema.org/draft/2020-12/json-schema-core
- RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457
- RFC 9110 — HTTP Semantics (idempotency and safe method semantics): https://www.rfc-editor.org/rfc/rfc9110
- NIST — AI Risk Management Framework (AI RMF 1.0): https://www.nist.gov/itl/ai-risk-management-framework
- OpenTelemetry — Semantic conventions for generative AI systems: https://opentelemetry.io/docs/specs/semconv/gen-ai/
- Pydantic v2 Documentation — Models, validators, and JSON Schema generation: https://docs.pydantic.dev/latest/concepts/models/
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.