Skip to content

Running ThinkLess in production

A checklist, roughly in the order problems show up.

Before launch

Calibrate every question on real traffic. The bundled thresholds come from small sets. Label a few hundred examples per question and run thinkless calibrate; see the calibration guide. Fix the accuracy target per question before looking at results.

Put a deterministic guard on every consequential action. A decision model choosing refund should never be the only thing between a message and a refund. In the support demo, code checks that the order belongs to the customer, that a duplicate charge actually exists in the payment records, and that the amount is under the automatic limit. The models decide what the customer wants; code decides whether it is allowed.

Decide what uncertainty means for each question. decision.is_(value) never matches an uncertain answer. For safety questions, handle the unsure case explicitly and fail closed: the demo blocks automatic refunds when the injection check leans yes without reaching its threshold.

Keep escalation context on. An escalated question reaches the LLM without its siblings unless the engine passes the settled ones along, and that can flip answers (see escalation context). It is on by default.

Order the cascade by cost, then check it by trace. Rules first, local models next, hosted decision models after, the LLM last. Then read a day of traces: a provider that escalates nearly every time it is asked is costing latency without saving anything, and belongs off that question (providers=(...) on the question).

Try purpose-built models, and measure them on your traffic. A classifier trained for one narrow question can beat a general decision model, but a model named for your problem is not automatically trained on your problem. In our tests, protectai/deberta-v3-base-prompt-injection-v2, a widely used prompt injection classifier, flagged ordinary support tickets (a card form bug, a promo code complaint, messages in Spanish and German) as injections with full confidence, and missed a policy-override attempt. It was trained on jailbreaks aimed at LLM apps, not on customer messages. HFClassifier makes this kind of comparison a few lines, and thinkless calibrate shows whether any threshold makes the model safe to use. Mind GPU memory when adding models.

Deployment

Warm up at startup, with your own questions. Call engine.warmup(QUESTIONS) before accepting traffic. It loads every model (10 to 60 seconds for the local ones) and then has each non-LLM provider answer your actual questions twice, so GPU kernels are specialized for the real input shapes. On the support demo this took GLiNER's first request from 650 ms to the same 130 ms as every later one. LLM providers are never called during warmup.

Share one engine per process. Providers load weights once and serialize GPU access with a lock. Build the engine at startup and reuse it across requests. In async services use adecide and agenerate, which run on worker threads.

Size the GPU. Measured peak allocation with Laya, GLiNER base and Qwen3-1.7B loaded together: 6.7 GB. On CPU-only hosts, GLiNER stays fast (about 85 ms) while Laya slows to about 650 ms per call.

Serve one engine to many agents. thinkless serve puts an engine behind HTTP, with a System One compatible endpoint, and thinkless mcp exposes its questions as MCP tools; see serving.

Pin versions. GLiNER2 requires transformers<5. Model weights on the Hugging Face Hub can change under a moving revision; pin revisions for reproducible behavior.

Limits

Give every request a deadline. Engine(deadline_ms=...), or decide(..., deadline_ms=...) per call, bounds the time a decision may take. The deadline is checked before each provider: once it has passed, the rest of the cascade is skipped and open questions come back uncertain with the best answer so far. A provider call already running is not interrupted, so give hosted clients their own timeout as well.

Cap what the engine may spend. SpendLimit is a dollar budget over the engine's lifetime or a rolling window:

from thinkless import Engine, SpendLimit

engine = Engine(providers, llm=llm, spend_limit=SpendLimit(50.0, window_s=86400))

with engine.run("ticket", max_cost_usd=0.02):   # and a cap for one run
    ...

Past a limit, paid providers are skipped (their attempts are recorded with reason spend_limit), rules and local models keep answering, and engine.generate raises SpendLimitError. The check runs before each paid call, so the call that crosses the limit completes. limit.spent_usd and limit.remaining_usd feed a dashboard.

Observability

Keep the JSONL audit trail. One file per run, root span last. It records which plane made every decision, with confidences and thresholds, which is what you need when someone asks why the agent did something.

Decide on content capture deliberately. THINKLESS_CAPTURE_CONTENT=false keeps structure, timings, labels and confidences while redacting inputs, prompts, completions, extracted values and tool arguments. The OpenTelemetry sink drops content by default regardless.

Export to your tracing backend. OTelSink maps spans onto OpenTelemetry with GenAI semantic convention attributes. Alert on the numbers that drift: escalation rate per question, share of decisions reaching the LLM, and uncertain decisions per hour.

Check for drift on a schedule. Traffic changes, and confidence distributions move with it. thinkless trace drift compares two periods of traces question by question: the share reaching the LLM, the share not accepted, the answer distribution and the mean confidence. It exits with status 1 when a question crosses a threshold, so a nightly job can page someone:

thinkless trace drift --baseline traces/2026-09 --current traces/2026-10

Turn traces into labels. thinkless trace export writes traced decisions as rows in the format thinkless calibrate reads. Export the uncertain and escalated ones, label them, and recalibrate:

thinkless trace export traces/2026-10 --out to_label.jsonl --status uncertain --limit 500

Both need traces written with content capture on, at least for a sample.

Log through the thinkless logger. The library never configures logging itself. thinkless.configure_logging(json_format=True) emits one JSON object per line for log shippers.

Rolling out

Start in shadow. Shadow mode runs the engine next to your current code on live traffic, changes nothing it returns, and reports agreement, projected savings and a verdict per question. Switch a question over once its verdict is ready, and keep a small audit shadow running afterwards. The migration guide walks through it.

Watch the tail, not the mean. Escalations are where latency hides: a ticket that escalates one question pays for a full LLM call. Track p95 latency per mode, not only the average.

Recalibrate on change. New question wording, a new model version, or a shift in traffic all move confidence distributions.

Security

Treat all input as untrusted. Questions constrain what a provider can answer, which removes whole classes of injection that free-form prompting allows, but the LLM in the reasoning plane still reads user text. Keep facts and instructions separate in generation prompts, and never let a generated reply authorize an action.

Check generated text before sending it. The demo's grounding rule rejects a reply that mentions a money amount absent from the facts, or an identifier that appears in neither the facts nor the customer's message, and falls back to a safe template.

Keep secrets out of traces. API keys never appear in spans, but tool arguments and results do when content capture is on. Redact at the tool, or turn capture off.