ArtikelIntegrationer

Giltig JSON med fel innehåll: validera modellens strukturerade svar

Svaret parsades utan invändning. Schemat sa OK. Ändå ligger det nu en återbetalning i kön mot ett ordernummer som aldrig har funnits, med statusen delvis_aterbetald som du inte har i din databas. Formatet var aldrig problemet — och det är formatet leverantörens garanti handlar om.

Publicerad · Senast granskad

En simiansk profil av tunna neurala noder till vänster om en rad mörka datablock som passerar en öppen mätram i metall, där ett block lyser acid-grönt.
Grinden släpper igenom det som håller formen. Vad som står i paketet är en annan kontroll.
Två närliggande texter täcker angränsande problem. Kontraktstesta AI-integrationen handlar om en CI-svit som körs före en modelluppdatering och jämför beteendet mellan versioner. Den här artikeln handlar om varje enskilt svar i drift, i kodvägen mellan API-anropet och biverkan. Promptinjektion i din egen app handlar om indata — text utifrån som blir instruktioner. Den här handlar om utdata: svaret som redan har producerats, oavsett varför det ser ut som det gör.

Kortfattat

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.

Samma schemafil betyder olika mycket

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
1. Påhittat enum-värde. 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.
2. Tyst typbyte. Beloppet kom som sträng. Med ett rent schema avvisas det. Med en validator som är konfigurerad att typomvandla blir "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.
3. Tomt obligatoriskt fält. Här sitter en fälla som är en direkt följd av strikt läge. OpenAI kräver att alla fält anges som 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.
4. Tom sträng där ett id skulle stå. Samma orsak som ovan, men värre i felsökningen: "" 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.
5. Uppdiktat id. 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

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.

Ett domänfel går aldrig över. Om 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.
Loopen rättar formen, inte fakta. Skickar du tillbaka "beloppSek måste vara ett heltal" får du ett heltal. Du får ingen garanti för att det är rätt heltal. Efter tre varv är sannolikheten att svaret är giltigt högre; sannolikheten att det är sant är i stort sett densamma. Ett tak på två omförsök gör den skillnaden synlig i stället för att gömma den bakom en grön mätare.
Kostnaden är linjär och lätt att räkna på. Räkneexempel med Claude Sonnet 5 till det ordinarie pris som Anthropic bekräftade den 10 augusti 2026 — 2 USD per miljon indatatokens och 10 USD per miljon utdatatokens: ett anrop med 3 000 indatatokens och 500 utdatatokens kostar 0,011 USD per försök. Två extra varv lägger på minst 0,022 USD per avvisat svar, och indatan växer varje varv eftersom det förra svaret och felet följer med. Vid 1 000 avvisade svar per dygn blir det cirka 22 USD per dygn, alltså cirka 660 USD per 30 dygn — för svar som ändå kan vara fel.
Utan tak bygger du en förstärkare. När leverantören får en störning och svaren blir sämre, går varje anrop från ett till n anrop. Din trafik mot ett läge som redan är ansträngt tredubblas i exakt fel ögonblick, och latensen i p99 följer med. Räkna med första-anrops-kostnaden också: både OpenAI och Anthropic dokumenterar extra latens första gången ett schema används, och Anthropic anger att den kompilerade grammatiken cachas 24 timmar från senaste användning och att cachen ogiltigförklaras om schemat eller verktygsuppsättningen ändras.

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

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.


Källor

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.