Constrained decoding löser en enda klass av fel: att svaret inte följer schemat. Kvar står påhittade enum-värden i lägen där schemat inte tillämpades, tysta typbyten, "null" som text, obligatoriska fält utan innehåll och plausibla men uppdiktade id:n. Ingen av de tre leverantörernas garantier säger något om den klassen — Google skriver rakt ut att du alltid ska validera värdena i din egen applikation. Bygg därför två lager: leverantörens schema för transporten, ditt eget för betydelsen.
Vad leverantörens garanti faktiskt täcker
Börja med att läsa löftet ordagrant. OpenAI beskriver Structured Outputs som en funktion som säkerställer att modellen alltid genererar svar som följer det JSON-schema du angett, så att du inte behöver oroa dig för att modellen utelämnar en obligatorisk nyckel eller hittar på ett ogiltigt enum-värde. Samma dokumentation skriver, några stycken senare, att strukturerade utdata fortfarande kan innehålla misstag, och att modellen alltid försöker följa schemat — vilket kan ge hallucinationer om indatan är helt orelaterad till schemat. Anthropic beskriver mekanismen som constrained decoding och säger att strukturerade utdata garanterar schemaenliga svar; något påstående om att innehållet är sant finns inte på sidan. Google är kortast och tydligast: "While output is syntactically correct JSON, always validate values in your application."
Garantin är verklig och värd att slå på. Den är bara smalare än den låter, och olika bred hos olika leverantörer.
Anthropics dokumentation listar minimum, maximum, multipleOf, minLength och maxLength som inte stödda i strukturerade utdata (läst 19 september 2026). OpenAI listar samma numeriska villkor som stödda. Ett beloppstak går alltså att uttrycka i leverantörsschemat hos den ena och inte hos den andra. Skriver du regeln bara i schemat och byter leverantör försvinner den utan felmeddelande — vilket är ett gott skäl att låta leverantörsschemat vara tunt och lägga villkoren i ditt eget lager oavsett vem du anropar i dag.
Två saker till som går förbi schemat i båda ändarna. Ett avbrutet svar är inte ett schemabrott utan ett trasigt anrop: OpenAI dokumenterar att ett avbrott upptäcks på att svarets status är incomplete med anledningen max_output_tokens. Och en vägran följer inte nödvändigtvis schemat alls — därför finns ett separat refusal-fält. Kontrollera stoppanledningen före du ens skickar strängen till en validator, annars felklassar du ett trunkerat svar som ett innehållsfel och skickar det vidare till en reparationsloop som aldrig kan lyckas.
Fem svar som passerar parsern
Det här är felen som når produktionskoden. Alla fem är giltig JSON, och tre av fem passerar ett schema som är skrivet på det sätt de flesta scheman skrivs.
{"atgard":"delvis_aterbetald","orderId":"SO-88213","beloppSek":24900} // 1
{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":"24900"} // 2
{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":null} // 3
{"atgard":"aterbetalning","orderId":"","beloppSek":24900} // 4
{"atgard":"aterbetalning","orderId":"SO-90001","beloppSek":24900} // 5
delvis_aterbetald är ett rimligt svenskt ord som inte finns i din uppräkning. Constrained decoding stoppar det — men bara där schemat faktiskt tillämpades. Verktygsanrop utan strikt läge, ett äldre modellval, ett proxylager eller en kodväg som föll tillbaka på fritextläge vid fel har ingen sådan spärr. Anta aldrig att schemat var påslaget för just det svar du håller i handen."24900" till 24900 och felet försvinner ur loggen — samtidigt som "24,9" eller "24 900" kan bli något helt annat än du tror.required, och dokumenterar att ett valfritt fält emuleras med en union med null. Konsekvensen: required betyder att nyckeln finns, inte att den har ett värde. Ett fält som är obligatoriskt i schemat kan alltså vara null, tom sträng eller tom array och ändå validera."" ser ut som ett värde i en logg, blir WHERE order_id = '' i en fråga och ger noll rader i stället för ett fel. En minLength-regel fångar det — men den regeln går inte att lägga i Anthropics leverantörsschema, så den måste ligga lokalt.SO-90001 har rätt prefix, rätt längd och rätt siffermönster. Det matchar varje pattern du orkar skriva. Det finns bara inte. Ingen schemamotor i världen kan avgöra det, eftersom svaret ligger i din databas och inte i schemat. Det här är det enda av de fem felen som kräver en uppslagning, och det är också det som gör mest skada när det går igenom.Lägg märke till att svårighetsgraden inte följer hur konstigt felet ser ut. Fel 1 stoppas av leverantören i det normala fallet. Fel 5 stoppas aldrig av någon, om du inte skriver kontrollen själv.
Ett schema som avvisar mer än syntax
Dela upp valideringen i två lager med olika uppgift. Leverantörsschemat följer med anropet, styr avkodningen och ska vara så litet att det fungerar likadant hos alla leverantörer du kan tänkas byta till. Ditt eget schema körs på svaret när det kommit tillbaka, känner inga leverantörsbegränsningar och får innehålla allt du vet om domänen.
// Lager 2: ditt eget schema. Kör alltid, även när leverantören
// säger att den har garanterat lager 1.
const svarSchema = {
type: 'object',
additionalProperties: false, // måste upprepas på VARJE objektnivå
required: ['atgard', 'orderId', 'beloppSek'],
properties: {
atgard: { type: 'string', enum: TILLATNA_ATGARDER }, // ur databasen
orderId: { type: 'string', minLength: 6, maxLength: 20,
pattern: '^SO-[0-9]{5}$' },
beloppSek: { type: 'integer', minimum: 1, maximum: 500000 }
}
};
const ajv = new Ajv({
allErrors: true, // du vill se alla fel, inte det första
coerceTypes: false, // annars blir "24900" tyst till 24900
useDefaults: false, // annars fylls ett saknat fält med ett gissat värde
removeAdditional: false // annars försvinner beviset på att svaret var fel
});
De tre sista flaggorna är Ajv:s standardvärden — de står här därför att de ofta är påslagna i delad valideringskod som en gång skrevs för HTML-formulär, där typomvandling och ifyllda standardvärden är en tjänst mot användaren. Återanvänds den koden för modellsvar blir samma bekvämlighet en tystad varning: båda flaggorna gör att ett felaktigt svar blir ett godkänt svar.
Tre regler som gör lager två värt sin kod
- Generera uppräkningen, skriv den inte.
TILLATNA_ATGARDERska komma ur samma tabell som utföraren läser, vid uppstart. En handskriven lista driver isär från verkligheten i samma stund som någon lägger till en åtgärd. Ett tak finns dessutom: OpenAI anger högst 1 000 enum-värden totalt i ett schema, så en uppräkning som växer fritt måste bli en uppslagning i stället. - Upprepa
additionalProperties: falsepå varje nivå. Den gäller bara objektet den står i. Ett nästlat objekt utan egen spärr släpper igenom fält du aldrig har definierat — och just där hamnar modellens improviserade extrafält. - Referensintegritet är en uppslagning, inte ett mönster. Efter schemat: slå upp ordern, kontrollera att den är i ett tillstånd som tillåter åtgärden och att beloppet ryms i det som återstår. Utfallet får tre skilda felkoder, inte en gemensam. Vem som får utlösa åtgärden är en tredje kontroll; den ägs av promptinjektionsartikeln och upprepas inte här.
Ett litet prov på att lager två sitter rätt: lägg till fältet "notis": "ok" i ett i övrigt korrekt svar och skicka in det. Avvisas det inte har du antingen glömt additionalProperties på någon nivå eller kört med removeAdditional påslaget, och då syns inte heller de fält modellen hittar på i produktion.
Reparationsloopen och taket den måste ha
Det ligger nära till hands att skicka tillbaka valideringsfelet och be om ett nytt försök. Det fungerar — för en av felklasserna. Klassificera därför innan du försöker igen, i stället för att räkna försök på allt.
const KAN_FORSOKAS_IGEN = new Set(['schema_typ', 'schema_saknat_falt', 'schema_enum']);
async function haemtaValiderat(anrop, maxOmforsok = 2) {
let senasteFel = null;
const maxForsok = 1 + maxOmforsok;
for (let forsok = 1; forsok <= maxForsok; forsok++) {
const svar = await anrop({ tidigareFel: senasteFel });
if (svar.stopAnledning === 'max_tokens') {
// Ett identiskt omförsök ger identiskt avbrott. Ändra något
// eller ge upp — räkna inte det här som ett reparationsförsök.
throw new ValideringsFel('trunkerat', { forsok });
}
const fel = validera(svar.json); // lager 2 + domänuppslagning
if (!fel) return { data: svar.json, forsok };
if (!KAN_FORSOKAS_IGEN.has(fel.klass)) {
throw new ValideringsFel(fel.klass, { forsok, fel }); // ge upp direkt
}
senasteFel = fel;
}
throw new ValideringsFel('tak_natt', { forsok: maxForsok, fel: senasteFel });
}
Taket är inte en försiktighetsåtgärd utan en konsekvens av fyra saker som alla pekar åt samma håll.
SO-90001 inte finns kommer den inte att finnas nästa varv heller. Att låta modellen försöka igen ger den bara fler chanser att gissa ett ordernummer som råkar finnas — vilket är ett sämre utfall än att den misslyckas. Samma sak för behörighetsfel. Bara formfel hör hemma i loopen.En sista regel som är lätt att bryta av misstag: reparationsloopen får aldrig korsa biverkansgränsen. Validera färdigt, ge upp eller lyckas — och först därefter skriv till databasen eller anropa tredje part. En loop som utför halva åtgärden och sedan gör ett nytt försök har uppfunnit en dubblettbetalning.
Provet: mata integrationen med medvetet felaktiga svar
Det här provet hoppar över modellen helt. Fixturerna är strängar, testet är deterministiskt, det kostar ingenting och det kan köras i CI vid varje ändring. Det svarar på en enda fråga: stoppar mitt valideringsskikt de här svaren?
import test from 'node:test';
import assert from 'node:assert/strict';
import { hanteraModellsvar, biverkningar } from '../src/integration.js';
const FIXTURER = [
['okänt enum-värde', '{"atgard":"delvis_aterbetald","orderId":"SO-88213","beloppSek":24900}', 'schema_enum'],
['sträng i sifferfält', '{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":"24900"}', 'schema_typ'],
['text-null', '{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":"null"}', 'schema_typ'],
['äkta null', '{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":null}', 'schema_typ'],
['tomt id', '{"atgard":"aterbetalning","orderId":"","beloppSek":24900}', 'schema_monster'],
['okänt extrafält', '{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":24900,"notis":"ok"}', 'schema_okant_falt'],
['belopp över tak', '{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":9900000}', 'schema_intervall'],
['uppdiktat id', '{"atgard":"aterbetalning","orderId":"SO-90001","beloppSek":24900}', 'doman_okand_order'],
['trunkerat svar', '{"atgard":"aterbetalning","orderId":"SO-882', 'syntax']
];
for (const [namn, rasvar, forvantadKlass] of FIXTURER) {
test(`avvisar ${namn}`, async () => {
biverkningar.nollstall();
const utfall = await hanteraModellsvar(rasvar);
assert.equal(utfall.status, 'avvisat');
assert.equal(utfall.felklass, forvantadKlass);
assert.equal(biverkningar.antal(), 0, 'ingen biverkan fick ske');
});
}
// Positivt kontrollfall — utan det bevisar sviten ovan ingenting.
test('släpper igenom ett korrekt svar', async () => {
biverkningar.nollstall();
const utfall = await hanteraModellsvar(
'{"atgard":"aterbetalning","orderId":"SO-88213","beloppSek":24900}');
assert.equal(utfall.status, 'utfort');
assert.equal(biverkningar.antal(), 1);
});
Det positiva kontrollfallet är inte pynt. En validera() som kastar på allt — en trasig schemaladdning, en felstavad sökväg, en tom uppräkning — gör hela den negativa sviten grön. Utan minst ett fall som ska passera mäter du att koden säger nej, inte att den säger nej på rätt saker — samma sorts kosmetiska täckning som testguiden varnar för.
Två skärpningar när grunden sitter. Assertionen på biverkningar.antal() ska räkna faktiska utgående anrop, inte att en funktion returnerade fel — annars missar du en integration som avvisar svaret men redan hunnit skriva en rad. Och kör fixturerna mot samma ingång som produktionen använder, inte mot validera() direkt; det är kopplingen mellan validering och utförare som brukar vara trasig, inte validatorn. Vill du dessutom veta om modellen börjat svara annorlunda efter en versionsuppdatering är det en annan svit med ett annat syfte — den beskrivs i kontraktstesta AI-integrationen.
Vad du loggar när ett svar avvisas
"Ogiltigt modellsvar" är inte en logg, det är en känsla. Det du behöver veta en vecka senare är exakt vilket fält som fällde, på vilket villkor och hur ofta.
Per avvisat svar
- felets plats som JSON Pointer (
/beloppSek) och nyckelordet som fällde (type,enum,maximum) — det är kombinationen som är sökbar, inte felmeddelandets prosa, - felklassen och i vilket lager den uppstod: transport, schema eller domänuppslagning,
- försöksnummer och om svaret godkändes efter reparation — godkänt direkt och godkänt efter loop ska vara två skilda mätvärden,
- modellidentifierare och en hash av schemat, så att en förändring går att knyta till en konfiguration i stället för till ett datum,
- stoppanledningen från leverantören, som skiljer trunkering och vägran från innehållsfel.
Om råsvaret: logga en hash av det plus ett avkortat, maskerat utdrag av just det felande värdet. Hela svaret kan innehålla personuppgifter som modellen plockat ur indatan, och en avvisad-logg är typiskt den yta som har längst kvarhållning och bredast läsbehörighet i organisationen. Vill du ha hela svaret sparat, spara det separat med kort kvarhållning och en åtkomst som är motiverad.
Läs sedan siffrorna åt rätt håll. Om 4 % av svaren faller på samma fält och samma nyckelord är det nästan aldrig ett modellfel — det är en uppräkning som saknar ett värde verksamheten faktiskt använder, eller en instruktion som ber om något schemat inte tillåter. Ett mätvärde per (fält, nyckelord) gör den skillnaden synlig på en gång. Ett mätvärde per "antal avvisade svar" gör den osynlig för alltid.
Garantins lydelse, kravet på required och additionalProperties: false, enum-taket, refusal-fältet, max_output_tokens och den extra latensen vid första anropet med ett nytt schema är kontrollerade mot OpenAI:s Structured Outputs. Constrained decoding, listan över icke stödda schemanyckelord samt cachningen av kompilerad grammatik kommer från Anthropics Structured outputs; läs där även avsnittet Invalid outputs innan du litar på enum-värden. Uppmaningen att alltid validera värdena i den egna applikationen är hämtad ur Googles Structured output. Standardvärden och beskrivningar för coerceTypes, useDefaults, removeAdditional och allErrors är kontrollerade mot Ajv Options. Prisuppgifterna för Claude Sonnet 5 är kontrollerade mot Anthropics release notes, där introduktionspriset 2/10 USD bekräftades som ordinarie pris den 10 augusti 2026. Samtliga källor lästa på nytt 19 september 2026.