Streaming met tool calls: gedeeltelijke output en afgebroken calls

Door Ivo Donker — samengesteld met AI-ondersteuning · 7 augustus 2026

De dualiteit van streaming en function calling in productie

Het gelijktijdig toepassen van Server-Sent Events (SSE) en functie-aanroepen introduceert een uniek architecturaal vraagstuk in productie-omgevingen. Los van elkaar zijn beide patronen transparant: streaming levert direct verwerkbare tekst-tokens voor gebruikersinteractie, terwijl function calling een gestructureerde JSON-data-envelop genereert voor backend-systemen. Raadpleeg de handleiding over streaming responses bij LLM-API's voor de basisprincipes van SSE-verbindingen en token-buffering. Wanneer u deze twee technieken combineert, verandert het gedrag van de API-stream fundamenteel. Een Large Language Model (LLM) streamt de argumenten van een functie-aanroep namelijk niet als één valide JSON-object, maar als een reeks gefragmenteerde tekstschijven (delta's) gedurende de tijdsduur van het verzoek. Zie voor de fundamentele werking van functie-aanroepen en schema-definities het overzicht van function calling en tool-gebruik.

De combinatie van streaming en tool calls wijkt af van reguliere text-streaming omdat de tussentijdse gegevensstroom onbruikbaar is voor directe verwerking door externe applicatielogica. Zolang het laatste token van een argumenten-array niet door de server is verzonden, bevindt het geserialiseerde JSON-fragment zich in een syntactisch onvolledige staat. Het te vroeg parsen van een geaccumuleerde buffer leidt onherroepelijk tot runtime-uitzonderingen. Bovendien kan een stream op elk willekeurig punt worden onderbroken door een netwerkfout, client-disconnectie, of provider-timeout. In een standaard tekststream resulteert een onderbreking slechts in een afgekapte zin op het scherm van de gebruiker. Bij streaming tool calls kan een onderbreking leiden tot een half ontvangen payload, waardoor backend-systemen onzeker zijn over de integriteit en de intentie van het verzoek.

In dit artikel analyseren we de specifieke faalmodi van streaming tool calls binnen productie-omgevingen. Voor elke faalmodus behandelen we de systematische detectie, concrete mitigatiestrategieën en de bijbehorende operationele kosten op het gebied van latency, financieel budget en systeemcomplexiteit.

Faalmodus 1: Fragmentatie en accumulatiefouten bij argumentdelta's

Faalmodus: Het model verdeelt de JSON-argumenten van een tool call over tientallen losse SSE-chunks. Wanneer meerdere tool calls in parallel worden aangeroepen, verzendt de API de argumenten-delta's dooreengeweven, gecorreleerd via een index-veld. Indien de verwerkende applicatie deze chunks simpelweg achter elkaar plakt zonder rekening te houden met de specifieke tool-index, ontstaat een verminkte string die niet kan worden geanalyseerd.

Detectie: De detectie vindt plaats op het niveau van het stream-parsing mechanisme. Zodra JSON.parse() een SyntaxError genereert bij het verwerken van een geaccumuleerde buffer, of wanneer verplichte velden ontbreken na schema-validatie, is er sprake van een accumulatiefout of een ontbrekende delta.

Mitigatie: Implementeer een expliciete buffering-laag die inkomende chunks opdeelt in twee gescheiden kanalen: directe tekst-output voor de gebruikersinterface en geïndexeerde argument-buffers voor tool calls. Elke inkomende tool-delta moet worden toegewezen aan een interne staat-tabel op basis van de meegeleverde index en id. Pas wanneer de stream het expliciete eindsignaal verzendt, mag de buffer per index worden samengevoegd en aan een JSON-parser en schema-validator worden aangeboden.

Onderstaande provider-onafhankelijke pseudocode illustreert hoe u een SSE-stream verwerkt met strikte scheiding van tekst en tool-delta's, inclusief een time-out op de stroom en een expliciet foutpad.

async function processLLMStream(streamReader, timeoutMs) {
  const toolCallBuffers = new Map();
  let textBuffer = "";
  
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error("STREAM_TIMEOUT")), timeoutMs)
  );

  try {
    await Promise.race([
      (async () => {
        for await (const chunk of streamReader) {
          if (chunk.type === "text_delta") {
            textBuffer += chunk.content;
            emitUIUpdate(chunk.content);
          } else if (chunk.type === "tool_delta") {
            const index = chunk.index;
            if (!toolCallBuffers.has(index)) {
              toolCallBuffers.set(index, { id: chunk.id, name: chunk.name, argsText: "" });
            }
            toolCallBuffers.get(index).argsText += chunk.args_delta;
          }
        }
      })(),
      timeoutPromise
    ]);
  } catch (error) {
    logError("Streamverwerking mislukt of getime-out", error);
    throw new StreamProcessingException("Stream onderbroken voor accumulatie", error);
  }

  const validatedToolCalls = [];
  for (const [index, call] of toolCallBuffers.entries()) {
    try {
      const parsedArgs = JSON.parse(call.argsText);
      validatedToolCalls.push({ id: call.id, name: call.name, args: parsedArgs });
    } catch (parseError) {
      throw new InvalidToolPayloadException(`Ongeldige JSON in tool call index ${index}`, parseError);
    }
  }
  return { text: textBuffer, toolCalls: validatedToolCalls };
}

Kosten van de mitigatie:

Faalmodus 2: Onvolledige streams en afgebroken verbindingen

Faalmodus: De SSE-verbinding wordt voortijdig verbroken door een wegvallende netwerkverbinding, het overschrijden van de maximale responstijd, of een actieve annulering door de client. De ontvangen buffer bevat wel een gedeeltelijke JSON-structuur, maar het eindteken van de stream ontbreekt.

Detectie: Inspecteer de metadata van de stream bij het sluiten van het transportkanaal. Een correct afgesloten stream bevat een expliciete status-indicator, zoals finish_reason. Indien de stroom sluit zonder dat finish_reason gelijk is aan tool_calls of stop (bijvoorbeeld bij length of wanneer het veld volledig ontbreekt), is het signaal onvolledig. Tevens zal een poging om de geaccumuleerde string te parsen falen met een onverwacht einde van de invoer.

Mitigatie: Pas een strikt "alles-of-niets"-beginsel toe op de verwerking van tool calls. Gedeeltelijk ontvangen argument-strings mogen nooit geforceerd worden gerepareerd of gedeeltelijk worden geëxecuteerd. Indien de stream onderbroken wordt voordat het sluitings-event is ontvangen, dient de gehele payload van de betreffende tool call te worden goedgegooid. Het systeem moet de status van de sessie terugrollen naar de laatst bekende valide staat. Lees voor het opzetten van robuuste controlemechanismen en geautomatiseerde validaties ook de gids over het testen van LLM-integraties.

Het onderstaande fragment toont hoe een applicatie de finale status van een stream evalueert en bij een afgebroken verbinding een gecontroleerde uitzondering opwerpt in plaats van de onvolledige data door te sturen naar bedrijfskritische systemen.

function finalizeStreamPayload(streamState) {
  if (!streamState.isCompleted) {
    throw new AbortedStreamException("Stream verbroken voordat de server het eindsignaal stuurde.");
  }

  if (streamState.finishReason === "length") {
    throw new TokenLimitExceededException("Model heeft de max_tokens limiet bereikt tijdens tool call.");
  }

  if (streamState.finishReason !== "tool_calls" && streamState.finishReason !== "stop") {
    throw new InvalidFinishReasonException(`Onverwachte finish_reason: ${streamState.finishReason}`);
  }

  return streamState.toolCalls.map(call => {
    const parsed = safelyParseJSON(call.rawArgs);
    if (!parsed.success) {
      throw new MalformedPayloadException(`Onvolledige JSON voor tool ${call.name}`);
    }
    return { id: call.id, name: call.name, args: parsed.data };
  });
}

Kosten van de mitigatie:

Faalmodus 3: Dubbele uitvoering bij onzekere execution-states

Faalmodus: Het model verstuurt de volledige argumenten van een tool call via de stream. De client ontvangt de argumenten, de stream sluit, en de client start de uitvoering van de externe functie (bijvoorbeeld een betalingsopdracht of een database-mutatie). Indien de netwerkverbinding direct na de uitvoering wegvalt — maar voordat de client het resultaat van de functie kan terugkoppelen aan het LLM — kan een automatische retry-handler het verzoek opnieuw aanbieden. Hierdoor bestaat het risico dat de externe actie twee keer wordt uitgevoerd.

Detectie: Dit probleem manifesteer zich niet als een directe API-fout, maar als inconsistentie in externe systemen (zoals dubbele boekingen of dubbel aangemaakte resources). In de applicatielogging is dit zichtbaar doordat een verzoek opnieuw wordt geïnitieerd met dezelfde business-context maar een nieuw stream-ID.

Mitigatie: Voer tool calls nooit blindelings opnieuw uit bij een netwerkfout of afgebroken vervolgstream. Gebruik verplicht een unieke idempotency key voor elke externe actie die mutaties veroorzaakt. Deze sleutel moet worden afgeleid van het unieke tool call ID dat door de LLM-provider in de stream wordt meegeleverd, gecombineerd met de hash van de gevalideerde argumenten. Raadpleeg voor het opzetten van gedistribueerde unieke sleutels en de verwerking daarvan in backend-systemen het artikel over idempotentie bij LLM-calls.

Daarnaast is het noodzakelijk om automatische retry-mechanismen op de stream zelf af te kaderen. Voor de precieze configuratie van retry-strategieën bij wegvallende HTTP-verbindingen verwijzen we naar het overzicht over retries en exponentiële backoff.

async function executeToolCallWithIdempotency(toolCall, executionContext) {
  const idempotencyKey = `exec_${toolCall.id}_${hashPayload(toolCall.args)}`;
  
  const lockAcquired = await executionContext.idempotencyStore.lock(idempotencyKey);
  if (!lockAcquired) {
    return await executionContext.idempotencyStore.getResult(idempotencyKey);
  }

  try {
    const result = await Promise.race([
      executeExternalService(toolCall.name, toolCall.args),
      new Promise((_, reject) => setTimeout(() => reject(new Error("EXECUTION_TIMEOUT")), 5000))
    ]);
    
    await executionContext.idempotencyStore.save(idempotencyKey, result);
    return result;
  } catch (error) {
    await executionContext.idempotencyStore.clearLock(idempotencyKey);
    throw new ToolExecutionException(`Fout tijdens uitvoering van ${toolCall.name}`, error);
  }
}

Kosten van de mitigatie:

Faalmodus 4: Provider-side annulering, AbortControllers en tokenkosten

Faalmodus: De client breekt de HTTP-stream af met behulp van een AbortController omdat een ingestelde client-timeout verstrijkt of de gebruiker op "annuleren" drukt. De veronderstelling dat het stopzetten van de stroom direct de kosten en verwerking aan de providerzijde stopzet, is in veel gevallen onjuist.

Detectie: Monitoring op de API-gateway toont een verschil tussen het aantal door de client ontvangen tokens en het aantal gefactureerde tokens in de gebruiks-rapportages van de provider. Voor een diepgaande analyse van responstijden, actieve annuleringen en de inrichting van uiterste verwerkingstijden verwijzen we naar het artikel over timeouts, annuleringen en deadline-budgetten.

Mitigatie: Wanneer een client een AbortController activeert, sluit de onderliggende TCP-verbinding. LLM-providers verwerken het stopzetten van een stream echter asynchroon. Het model kan intern doorgenereren totdat de eerstvolgende netwerk-write faalt. Voor een nauwkeurige cost-control dient het applicatiesysteem het verbroken verzoek te registreren in een observatie-module. Zie voor de opzet van dergelijke bewaking het document over observability en logging bij LLM-integraties.

Om onverwachte kosten te vermijden bij grote volumes, dienen systeemarchitecten daarnaast rekening te houden met de tarieven voor afgebroken gegenereerde tokens. Informatie over tariefstructuren en limieten is beschikbaar in de gids over rate limits en kostenbeheer.

Het onderstaande pseudocode-voorbeeld demonstreert de correcte hantering van een AbortController in combinatie met een timeout-budget en het opvangen van de annuleringsevent.

async function fetchStreamWithCancellation(apiEndpoint, payload, clientSignal) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10000);
  
  if (clientSignal) {
    clientSignal.addEventListener("abort", () => controller.abort());
  }

  try {
    const response = await fetch(apiEndpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      signal: controller.signal
    });
    clearTimeout(timeoutId);
    return response.body;
  } catch (error) {
    clearTimeout(timeoutId);
    if (error.name === "AbortError") {
      logMetric("stream_cancelled_by_client_or_timeout");
      throw new RequestCancelledException("API-verzoek geannuleerd via AbortSignal.");
    }
    throw new NetworkException("Transportfout tijdens API-verzoek", error);
  }
}

Kosten van de mitigatie:

Faalmodus 5: Spanningsveld tussen streaming en strict structured output

Faalmodus: Veel LLM-providers bieden de mogelijkheid om via gepatenteerde of open standaarden strikte JSON-schema's af te dwingen (structured output). Wanneer dit wordt gecombineerd met streaming, ontstaat een architecturaal spanningsveld. Om te garanderen dat de output 100% voldoet aan het meegegeven schema, moet de provider-engine het generatieproces op token-niveau beperken met een grammatica-masker (constrained sampling). Tussentijdse tekst-delta's voldoen per definitie nooit aan het volledige schema zolang het sluitteken niet is verwerkt.

Detectie: De applicatie ontvangt streaming chunks die op zichzelf geen geldige JSON vormen, of de provider retourneert een API-foutmelding (zoals HTTP status 400) omdat de combinatie van stream: true en bepaalde strikte schema-opties door de betreffende engine niet wordt ondersteund.

Mitigatie: Accepteer dat bij het streamen van tool-argumenten de syntactische validatie pas aan het einde van de stroom kan plaatsvinden. Gebruik het strikte schema als hulpmiddel voor de provider om het model te dwingen valide JSON te genereren, maar voer de definitieve schema-validatie (bijvoorbeeld via Zod of JSON Schema validators) pas uit in uw eigen applicatiecode nadat de stroom volledig is geaccumuleerd. Lees voor een diepgaande beschouwing over schema-afdwinging het overzicht van structured output en JSON-schema's.

Indien het voor uw bedrijfsvoering van kritiek belang is dat uitvallen door ongeldige JSON tot een absoluut minimum beperkt wordt, kan het noodzakelijk zijn om terug te vallen op robuuste verwerkingsstrategieën. Raadpleeg voor verdere continuïteitsoplossingen de gids over graceful degradation bij LLM-uitval.

Kosten van de mitigatie:

Beslisboom en integratiechecklist voor streaming tool calls

Het combineren van streaming en tool calls is een krachtig patroon, maar het introduceert aanzienlijke overhead. Gebruik onderstaande beslisboom en overwegingen om te bepalen of streaming voor uw specifieke tool-call scenario de juiste keuze is.

Wanneer wel streaming met tool calls gebruiken?

Wanneer géén streaming met tool calls gebruiken?

Productie-checklist voor uitrol

  1. Buffers opzetten: Is er een gescheiden buffer aanwezig per tool_call.index die pas wordt vrijgegeven bij het sluitselement van de stream?
  2. Eindsignaal-controle: Controleert de parser expliciet of de stream is gesloten met finish_reason: "tool_calls" of finish_reason: "stop"?
  3. Idempotentie geborgd: Wordt het door de provider meegeleverde tool call ID doorgegeven aan de uitvoerende backend-service als idempotency key?
  4. Timeout-budgetten: Is er een harde tijdsgrens ingesteld voor de gehele duur van de stream, gekoppeld aan een AbortController?
  5. Foutpad-afhandeling: Wordt bij een gefragmenteerde of mislukte JSON-parse de actie volledig geannuleerd zonder gedeeltelijke uitvoering?

Dit artikel behandelt het verwerken van retries en backoff, de inrichting van een centrale proxy-architectuur en de selectie van geschikte modellen voor tool-gebruik niet; zie hiervoor de artikelen over retries en exponentiële backoff bij LLM-calls, het zelf hosten van een LLM-gateway, en het kiezen van het juiste model voor function calling.