An LLM can write a convincing answer while making the wrong decision. That is the uncomfortable gap behind many production AI failures: the system treats a probabilistic model as the final authority on facts that should be fixed, authorized, or calculated exactly.
The answer is not to remove the LLM. It is to give it the right job. Use deterministic code for boundaries and decisions that must be repeatable; use the LLM for interpretation, extraction, explanation, and ambiguity. This article shows how to build that split, test it, and keep it useful as the workflow grows.
The core pattern: rules decide, LLMs interpret
Think of an AI workflow as four separate layers:
| Layer | Best job | Should it be deterministic? |
|---|---|---|
| Input boundary | validate identity, schema, size, and consent | Yes |
| Decision policy | permissions, pricing, eligibility, risk, routing | Yes |
| Language layer | classify messy text, extract fields, summarize, explain | No, but constrained |
| Action boundary | execute approved operations and record evidence | Yes |
This is not a claim that models cannot reason. It is a design choice: do not ask a model to enforce a rule your application can enforce exactly. Recent AWS guidance for agentic systems similarly recommends server-side checks at the point of tool use, least-privilege boundaries, tracing, and canary testing for behavior changes.
For example, a support workflow may ask an LLM to read a customer message and return a structured intent. The application, not the model, decides whether a refund is allowed and whether a ticket can be changed.
customer message
-> LLM extracts intent + evidence
-> schema validation
-> deterministic policy evaluates eligibility
-> approved action or human review
-> LLM explains the outcome in plain language
The key benefit is debuggability. When a refund is blocked, you can tell whether extraction failed, a required fact was missing, or a rule intentionally denied the action. “The model decided” is not an operational explanation.
Find the decisions that should never depend on a prompt
Start by listing every workflow decision and placing it in one of three buckets.
Hard rules
Hard rules have a single correct answer from trusted inputs. Put them in code, a policy engine, or a database constraint.
- Is the caller authenticated for this tenant?
- Is the requested action within their role and spend limit?
- Is an invoice overdue according to the ledger?
- Does a request satisfy a required JSON schema?
- Is a tool call idempotent, rate-limited, and within its allowed scope?
An LLM can help explain a denial, but it should not be the source of truth for it.
Soft judgments
Soft judgments need context, language understanding, or a trade-off. These suit an LLM, ideally with an explicit output contract.
- Which issue category best fits this free-form report?
- Which facts in a document answer the user’s question?
- Is this explanation clear enough for a nontechnical reader?
- Which knowledge-base article is most relevant after retrieval?
Escalation cases
Some tasks are neither safe to automate nor simple enough for a static rule. Route them to a reviewer with the facts the reviewer needs. Do not hide uncertainty behind confident prose.
type Disposition =
| { kind: "allow"; policyId: string }
| { kind: "deny"; policyId: string; reason: string }
| { kind: "review"; policyId: string; questions: string[] };
function decideRefund(input: {
tenantId: string;
actorRole: "admin" | "agent" | "viewer";
amountCents: number;
daysSincePurchase: number;
}): Disposition {
if (input.actorRole === "viewer") {
return { kind: "deny", policyId: "refund-role-v1", reason: "role cannot issue refunds" };
}
if (input.amountCents > 50_000) {
return { kind: "review", policyId: "refund-limit-v1", questions: ["Is manager approval attached?"] };
}
if (input.daysSincePurchase > 30) {
return { kind: "deny", policyId: "refund-window-v1", reason: "outside refund window" };
}
return { kind: "allow", policyId: "refund-standard-v1" };
}
Notice what is missing: no model call is needed to determine the limit. That makes the result stable, unit-testable, and easy to audit.
Give the model a narrow, typed contract
The LLM should return observations, not permissions. A good contract names the facts it may infer, records uncertainty, and leaves sensitive decisions to code.
import { z } from "zod";
const SupportSignal = z.object({
intent: z.enum(["refund_request", "billing_question", "technical_issue", "other"]),
orderId: z.string().regex(/^ord_[a-z0-9]+$/).optional(),
requestedAmountCents: z.number().int().nonnegative().optional(),
evidence: z.array(z.string()).max(3),
confidence: z.number().min(0).max(1),
needsHumanReview: z.boolean()
});
type SupportSignal = z.infer<typeof SupportSignal>;
In the prompt, ask for only this JSON shape. After receiving it, parse it with the schema. If parsing fails, retry once with a repair instruction or route the task to review. Do not silently coerce a malformed amount, unknown intent, or invented order ID into an action.
This division also protects against an easy mistake: treating text returned by a document, browser page, or tool as authority. Retrieved content can influence the LLM’s interpretation; it must not be allowed to override authorization or policy. Fetch authoritative facts from the systems that own them.
Build a decision pipeline, not a giant prompt
An implementation becomes easier to reason about when each stage has one responsibility.
async function handleSupportMessage(request: Request) {
const context = await authenticateAndLoadTenant(request);
const message = await validateIncomingMessage(request);
const signal = SupportSignal.parse(await extractSignal(message));
const order = signal.orderId
? await loadOrderForTenant(context.tenantId, signal.orderId)
: null;
const disposition = !order || signal.needsHumanReview || signal.confidence < 0.8
? { kind: "review", policyId: "missing-or-uncertain-facts-v1", questions: ["Verify order and request details"] }
: decideRefund({
tenantId: context.tenantId,
actorRole: context.actorRole,
amountCents: signal.requestedAmountCents ?? 0,
daysSincePurchase: daysSince(order.purchasedAt)
});
await writeAuditRecord({ context, signal, disposition, orderId: order?.id });
if (disposition.kind !== "allow") return renderSafeResponse(disposition, signal);
return executeIdempotentRefund(context, order!, disposition.policyId);
}
This pattern makes threat boundaries visible:
-
authenticateAndLoadTenantgets identity from a trusted session, not model output. -
loadOrderForTenantscopes the lookup before it reaches a business rule. -
decideRefundis pure, so it can be tested with a table of cases. -
executeIdempotentRefundowns the side effect and records the policy that allowed it.
The LLM can still make the experience feel natural. It can extract fields from a rambling request, draft a kind response, and suggest what information is missing. It cannot manufacture the right to change a record.
Handle disagreement without pretending the model is a judge
Many teams add a second model when the first output looks uncertain. That can be useful for language tasks, but it is not a substitute for policy. Two models agreeing that an action is allowed does not make it authorized.
Use disagreement as a routing signal instead:
- Compare model extraction with trusted records and deterministic checks.
- If the facts conflict, stop the automated path.
- Preserve the candidate output, relevant evidence, and policy result for review.
- Turn the resolved case into a regression fixture.
For a classification task, you might use a small model for a first pass and a stronger model only when confidence is low. For a payment, permission, deletion, or external message, the final authority should still be an application policy plus any required human approval.
Measure the workflow at three levels
HTTP success tells you very little. A 200 response can conceal a wrong classification, a denied action that should have been allowed, or a costly retry loop.
Track three metric layers for each version of the workflow:
| Layer | Examples | Promotion question |
|---|---|---|
| Contract | schema-valid output, required facts present, tool argument checks | Did the interface remain intact? |
| Outcome | accepted resolution, correction rate, reviewer reversal, safety violation | Did it help the user without breaking policy? |
| Operations | p95 latency, error rate, cost per completed task, queue age | Can the system sustain it? |
This mirrors a useful observation from a feature-flag guide for LLM features: computational metrics are easy, deterministic behavioral checks need more setup, and semantic quality needs a rubric or human labeling. Do not let a latency improvement hide a semantic regression.
Segment metrics by tenant plan, language, workflow type, and risk tier. A global average can be healthy while a low-volume but important category fails badly.
Test rules and model behavior differently
Rules deserve ordinary unit tests because they should behave the same way every time.
import { expect, test } from "vitest";
test("large refunds always require review", () => {
expect(decideRefund({
tenantId: "t_1", actorRole: "admin", amountCents: 50_001, daysSincePurchase: 2
})).toMatchObject({ kind: "review", policyId: "refund-limit-v1" });
});
Model behavior needs a different test set: representative messages, malformed inputs, adversarial instructions inside quoted text, sparse evidence, multilingual requests, and cases that must be escalated. Assert hard properties exactly—schema validity, no unapproved tool call, correct tenant scope. Score soft properties with a rubric and sampled human review.
Keep a fixture whenever a production case is corrected. Over time, the suite becomes a map of real customer ambiguity, not a collection of polished demos.
Roll out a policy-model change safely
Treat a prompt, retrieval setting, tool description, model, and policy rule as a release bundle. Record version IDs for each piece. If two components change at once, you cannot tell which one improved or harmed the result.
Use a small progression:
- Offline replay: run candidate and incumbent against saved, de-identified fixtures.
- Shadow path: run the candidate beside production, but keep it away from side effects and user-visible results.
- Limited exposure: give a stable, low-risk cohort the candidate path. Keep an immediate switch back.
- Promotion: expand only when contract, outcome, and operational thresholds pass with enough representative traffic.
Before the first user sees a change, write veto conditions. Examples include any cross-tenant lookup, any unauthorized action, schema failure above a small tolerance, or a correction rate above the incumbent. These are not averages to trade away for a prettier answer.
A practical starting checklist
If your current workflow is one large prompt, do not rewrite everything. Pick one irreversible or high-volume path and work through this list.
- List every proposed action and identify its authoritative data source.
- Move permission, eligibility, spend, and tenant-scope checks outside the prompt.
- Make the LLM output observations in a validated schema.
- Use trusted records to confirm identifiers before an action.
- Add an explicit
reviewdisposition; uncertainty should have a safe destination. - Make writes idempotent and log the policy version, input references, and outcome.
- Test rules with tables and model behavior with representative fixtures.
- Define release thresholds and vetoes before a new version receives traffic.
The goal is not a less capable AI feature. It is an AI feature whose strengths are useful and whose boundaries are inspectable. Let the model handle language and ambiguity. Let the system enforce the facts, permissions, and actions that customers depend on.
FAQ
When should I use rules instead of an LLM?
Use rules when a decision has a trusted input and a repeatable answer: authorization, pricing, eligibility, tenant scope, rate limits, schemas, and side-effect permissions. Use an LLM when the input is ambiguous language or the output is explanatory.
Can an LLM call a rule engine?
Yes. The safe pattern is for the LLM to request a narrow, validated operation, while the application or policy service evaluates the rule and returns the result. The model should not be able to bypass that service or supply its own authorization facts.
How do I prevent an AI workflow from acting on hallucinated IDs?
Validate the model’s output against a strict schema, then retrieve the record from an authoritative system using the authenticated tenant scope. Never use a model-generated identifier as proof that a record exists or belongs to the caller.
What should happen when the model is uncertain?
Make uncertainty explicit in the output contract and route it to a safe fallback: ask for missing information, use a deterministic alternate path, or create a review task. Do not lower a confidence threshold merely to increase automation.
Do deterministic rules make an AI feature less flexible?
They make high-stakes edges less flexible on purpose. The LLM remains free to understand varied language, summarize, and explain. Rules simply reserve fixed decisions and external actions for components that can be tested and audited exactly.
How should I test hybrid AI workflows?
Unit-test policies and side-effect guards with exact cases. Test the model layer with representative and adversarial fixtures, then assert strict properties such as schema validity, tenant scope, and forbidden-action avoidance. Evaluate softer qualities with rubrics and sampled review.
Top comments (0)