An accountant runs the same invoice through your system on Monday and on Thursday and gets two different totals. It does not matter that one of them was correct. The tool is now something the finance team cannot sign off on, because in finance and tax work the requirement is not "usually right." It is "the same input produces the same output, and we can show how."
Teams usually respond by setting temperature to 0. That helps, and it is not enough.
The short answer to "how do I make LLM output consistent for accounting" has two parts. First, reduce the variance at the model: greedy decoding, pinned versions, constrained output. Second, and more important, design the system so that the remaining variance cannot reach a number anyone relies on. The model reads documents. Code does arithmetic and applies rules. Validation catches disagreement. A stored record makes the answer permanent.
Why temperature 0 is not deterministic
Temperature 0 means greedy decoding: at each step, pick the token with the highest score. That removes the random sampling. It does not remove the small numerical differences in how those scores are computed from one run to the next, and when two candidate tokens are nearly tied, a tiny difference flips the choice. Every token after that point is conditioned on a different prefix, so the outputs diverge.
Where the differences come from:
Floating-point arithmetic is not associative. On a GPU, (a + b) + c and a + (b + c) can differ in the last bits. Matrix multiplications and normalizations sum thousands of values, and the order of summation depends on how the work was split up.
Batching changes the order. This is the big one for anything served behind an API. Inference servers batch your request together with whatever other requests arrive at the same moment. Many GPU kernels choose a different reduction strategy for different batch sizes, so the result for your request depends on how busy the server was. Thinking Machines Lab published a detailed analysis of this in 2025, identifying lack of batch invariance as the main reason the same prompt returns different completions at temperature 0. You do not control server load, so from your side it looks random.
Kernel and hardware selection. Libraries pick algorithms at runtime based on tensor shapes, GPU model, and driver version. Change the GPU type, the tensor-parallel degree, or the library version and the low-order bits change.
Mixture-of-experts routing. In some MoE implementations, tokens from different requests in a batch compete for expert capacity, so which expert processes your token can depend on the other requests in the batch.
Provider-side changes. With a hosted API, a model alias can be repointed to a new snapshot, and the serving stack behind a fixed snapshot can be updated without notice. Output for a fixed prompt can change overnight with no change on your side.
Your own inputs. Before blaming the model, check the prompt. A timestamp in the system prompt, retrieval results returned in a different order, an OCR step that is itself non-deterministic, or a dictionary serialized in arbitrary key order will all change the input, and a changed input is a different question.
What you can tighten at the model
These measures reduce variance. None of them eliminates it on shared infrastructure.
- Use greedy decoding and say so explicitly. Set
temperature: 0and do not rely on defaults. Where the API offers aseedparameter, set it, but read the documentation: OpenAI describes seeded determinism as best effort and returns asystem_fingerprintso you can detect when the backend changed. Not every provider offers a seed at all. - Pin the model version. Call a dated snapshot, never a floating alias. When self-hosting, pin the weights to a specific repository revision, pin the serving engine version and container image digest, and keep GPU type and parallelism settings fixed. Treat any change to that set as a model change that requires re-validation.
- Consider batch-invariant serving if you self-host. vLLM now documents a batch-invariant mode, enabled with
VLLM_BATCH_INVARIANT=1, that makes results independent of batch size on supported NVIDIA GPUs, at some cost in throughput. This is one of the few places where running your own hardware gives you a guarantee a shared API cannot. It is still worth treating as defense in depth rather than the whole answer. - Make the prompt byte-for-byte stable. Version prompts in source control. Sort retrieved context deterministically. Keep dates and request IDs out of the prompt unless the task needs them. Canonicalize document text before it reaches the model.
- Constrain the output. This deserves its own section.
Constrain the output to a schema
Structured outputs, also called constrained or guided decoding, restrict the model at every step to tokens that keep the output valid against a JSON schema you supply. OpenAI exposes this as response_format with type: "json_schema" and strict: true. vLLM supports the same request shape on its OpenAI-compatible server, using grammar backends such as xgrammar. Other engines have equivalents.
This does two things. It removes a whole class of failure: no malformed JSON, no missing fields, no commentary wrapped around the answer. And it shrinks the space in which variance can occur. A field that must be one of five enum values has far less room to drift than a sentence of free text.
Design the schema for the domain:
from typing import Literal, Optional
from pydantic import BaseModel
class LineItem(BaseModel):
description: str
quantity: str # exactly as printed
unit_price: str # exactly as printed, e.g. "1,250.00"
line_total: str
tax_code: Literal["STANDARD", "REDUCED", "ZERO", "EXEMPT", "UNKNOWN"]
source_text: str # verbatim snippet the values were read from
class InvoiceExtraction(BaseModel):
supplier_name: str
invoice_number: str
invoice_date: str # ISO 8601
currency: Literal["USD", "EUR", "GBP", "CAD"]
line_items: list[LineItem]
stated_subtotal: str
stated_tax: str
stated_total: str
notes: Optional[str] = NoneThree choices in that schema matter. Amounts are strings transcribed as printed, not floats, because binary floating point cannot represent most decimal amounts exactly and because you want to parse them yourself. Categories are closed enums with an explicit UNKNOWN, so the model has an honest way to decline instead of guessing. And each line carries the source text it came from, which makes review fast and makes fabricated values detectable.
A schema guarantees shape, not truth. The model can still put the wrong number in a well-formed field. That is what the next layers are for.
Let the model read and let code calculate
Language models are good at reading messy documents: finding the invoice number wherever the supplier chose to print it, recognizing that "Amt Due" means total, mapping a description to a category. They are unreliable at arithmetic across many numbers, and they have no stable knowledge of this year's thresholds or rates.
So divide the work accordingly:
| Task | Who does it | Why |
|---|---|---|
| Locate and transcribe fields from a document | Model | Tolerant of layout and wording |
| Classify a line into a closed set of categories | Model, with validation | Judgment on unstructured text |
| Parse amounts, sum lines, compute tax, round | Code, with decimal arithmetic | Exact and repeatable |
| Apply rates, thresholds, effective dates | Rules engine | Must be auditable and change-controlled |
| Decide whether a result is trustworthy | Validation layer | Deterministic checks |
| Resolve conflicts and exceptions | Human reviewer | Accountability |
The arithmetic is then ordinary code:
from decimal import Decimal, ROUND_HALF_UP
def money(s: str) -> Decimal:
return Decimal(s.replace(",", "").replace("$", "").strip())
subtotal = sum(money(li.line_total) for li in extraction.line_items)
rate = rules.rate_for(extraction) # a Decimal from the rules engine, never from the model
tax = (subtotal * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)The rounding mode, and whether tax is rounded per line or per invoice, is a policy decision that belongs in code where it can be reviewed, not something a model should improvise.
The validation layer
Finance documents are unusually friendly to validation, because they contain their own checksums. Use them.
- Schema validation. Already enforced by constrained decoding. Validate again in code anyway, since it costs nothing.
- Arithmetic cross-checks. Quantity times unit price should equal the line total. Line totals should sum to the stated subtotal. Subtotal plus tax should equal the stated total. When the document's own numbers reconcile with the extracted numbers, a transcription error is very unlikely.
- Reference checks. The supplier exists in the vendor master. The currency matches the supplier's country. The date falls in an open period. The tax code is valid for that jurisdiction.
- Grounding checks. Each extracted value appears in the source text. A value that cannot be found in the document is rejected outright.
- Agreement checks. For high-value items, run the extraction twice, or with two different models, and compare fields. Agreement raises confidence. Disagreement is a cheap, reliable signal to route the document to a person. This turns the model's variance into a detector instead of a defect.
Anything that fails goes to a review queue with the reasons attached. The system should never resolve a failed check by quietly picking one answer.
Rules belong in a rules engine
Tax rates, exemption thresholds, account mappings, and approval limits are rules with effective dates and owners. They should not live in a prompt, where a change is invisible and untestable. Keep them as versioned configuration that code evaluates:
- id: reduced-rate-eligible-category
effective_from: 2026-01-01
when:
tax_code: REDUCED
jurisdiction: EXAMPLE-STATE
then:
rate: "0.05"
gl_account: "2210"The values above are placeholders. The point is the structure: the model supplies facts (this line is category X), and the rules engine supplies consequences (category X at this date in this jurisdiction means this rate and this account). Every output then cites the rule IDs and versions that produced it. When a rate changes, you change one reviewed line of configuration and re-run the affected items, and the model is not involved.
Make the answer permanent: record, do not regenerate
The most practical form of determinism is not running the model a second time.
Compute a key from the document hash, the prompt version, the schema version, and the model version. Store the validated extraction under that key. When the same document comes back, return the stored result. The Monday and Thursday answers now match by construction.
The stored record is also your audit trail: input hash, model and engine version, prompt version, raw model output, validation results, rule versions applied, reviewer and timestamp if a person was involved. We cover the logging design in detail in audit trails for LLM systems your auditor will accept. When any version in the key changes, reprocessing is a deliberate act with a before-and-after comparison, not something that happens as a side effect.
A regression suite for every change
Build a golden set of documents with known-correct extractions. A few hundred, covering your real suppliers, formats, and awkward cases. Then:
- Run the whole set several times against the current configuration and measure field-level agreement between runs. This is your repeatability number, and it is worth tracking separately from accuracy.
- Run it on every change to the prompt, schema, model, engine version, or hardware. No change ships if accuracy or repeatability drops.
- Add every production disagreement and every reviewer correction to the set.
This is the same discipline as the evaluation harness we describe in three measurements every production LLM system needs, with run-to-run agreement added as a first-class metric.
What "deterministic enough" means
You will not get bitwise-identical output from a shared, batched inference service, and you do not need it. What a finance team and its auditors need is:
- The same document produces the same recorded result, every time.
- Every number on a return or ledger was computed by code from transcribed inputs, under a named rule version.
- Any output the system was unsure about was seen by a person.
- A change to any component is tested against known answers before it goes live.
If you are weighing whether a model running on your own hardware would give you tighter control over versions and batching for this kind of workload, get in touch and we can walk through the trade-offs.



