Net-Base Magazin

15.07.2026

ChatGPT API mit Delphi FMX/VCL: Robust anbinden mit SSE-Streaming, Cancel und Retry

Delphi-Praxis für die ChatGPT API in FMX/VCL: ein robuster Client mit SSE-Streaming, Cancel-Token, Backoff-Retry und defensivem JSON-Parsing – inklusive typischer Betriebsfallen.

15.07.2026

Vom Magazinthema zur Projektpraxis

Passende Leistungs- und Technikseiten zum Beitrag

„Wie binde ich die ChatGPT API mit Delphi FMX/VCL an, ohne dass mein Desktop-Client bei schlechter Leitung einfriert – und ohne dass ich mir beim ersten Rate-Limit ein Support-Ticket nach dem anderen einfange?“ Die Frage kommt selten von Teams, die noch nie eine API integriert haben. Sie kommt von Leuten, die den Betrieb kennen: Proxy-Zwang im Firmennetz, TLS-Inspection, sporadische Timeouts, Anwender mit Doppelklick-Finger und ein UI, das unter Last „Nicht antwortend“ zeigt.

Konstruierte, aber realistische Situation: Ein VCL-Tool hängt an einer individuellen Unternehmenssoftware. Es soll Textbausteine für Supportfälle generieren. Im Test läuft der einfache HTTP-POST – im Produktivnetz kommen aber (a) gelegentliche ENetHTTPClientException beim TLS-Handshake, (b) 429-Responses bei Lastspitzen und (c) die Anforderung, dass der Benutzer den Vorgang jederzeit abbrechen kann. Streaming (Server-Sent Events, SSE: ein HTTP-Response-Stream mit fortlaufenden „data: …“-Zeilen) ist UX-seitig interessant, darf aber die UI nicht blockieren.

Der folgende Refresh setzt deshalb auf drei Dinge: saubere Trennung (Client statt „HTTP im ButtonClick“), ein SSE-Reader im Worker-Thread mit UI-marshalling, und ein Cancel-/Retry-Design, das nicht nur im Idealfall funktioniert.

Warum „nur ein POST“ bei der ChatGPT API mit Delphi FMX/VCL kippt

In Desktop-Business-Software sind es typischerweise diese Kanten:

  • Threading: Netzwerk-Reads im UI-Thread führen zu Freezes; UI-Updates aus dem Worker-Thread führen zu sporadischen AVs.
  • Streaming-Formate: SSE ist kein „ein JSON-Dokument“, sondern eine Sequenz von Events. Zeilenparsing und „[DONE]“-Handling sind Pflicht.
  • Betriebseinflüsse: Proxy, Zertifikatskette, TLS-Inspection – plus Timeouts, die im Testnetz nie auftreten.
  • Retry-Strategie: „Bei Exception nochmal“ reicht nicht. 429 verlangt Backoff, 401/403 nicht.
  • Defensives JSON: Sie müssen mit fehlenden Feldern und abweichenden Strukturen rechnen, sonst ist der Parser die erste Absturzquelle.

Zwei technische Eckpunkte sind dabei gut belegbar: THTTPClient ist Delphis Standard-HTTP-Stack und unterstützt Timeouts, Requests und ProxySettings (siehe System.Net.HttpClient in der Embarcadero-Dokumentation: docwiki.embarcadero.com/RADStudio/en/System.Net.HttpClient.THTTPClient). Und UI-Updates gehören in VCL/FMX in den Main Thread – TThread.Queue ist dafür der robuste, asynchrone Standardmechanismus (Dokumentation: docwiki.embarcadero.com/RADStudio/en/System.Classes.TThread.Queue).

Architektur: ein testbarer Client statt Formular-Code

Wenn Sie die Integration länger als einen Sprint betreiben wollen, lohnt eine kleine Schichtung:

  • TOpenAIChatClient als Integrationskomponente (HTTP, SSE, Parsing, Fehlerobjekte).
  • Use-Case-Schicht (Prompt-Zusammensetzung, Domänenregeln, Redaction).
  • UI (Start/Cancel, Fortschritt, Anzeige, Copy/Save).

Source-Schnipsel: SSE-Streaming mit Cancel, Backoff-Retry und defensivem JSON

Der Code nutzt THTTPClient und System.JSON. Er zeigt bewusst einen Randfall, der in Legacy-Desktops oft fehlt: ein explizites Cancel, das den Stream sauber abbricht, plus Retry-Regeln, die zwischen Netzwerkfehler, 429 und „nicht retrybar“ unterscheiden. UI-Callbacks laufen über TThread.Queue, damit FMX/VCL stabil bleibt.

Delphi
unit NetBase.OpenAI.ChatClient;

interface

uses
  System.SysUtils, System.Classes, System.Net.URLClient, System.Net.HttpClient,
  System.JSON, System.Threading, System.SyncObjs;

type
  EChatApiError = class(Exception)
  private
    FHttpStatus: Integer;
    FResponseText: string;
  public
    constructor Create(const Msg: string; AHttpStatus: Integer; const AResponseText: string);
    property HttpStatus: Integer read FHttpStatus;
    property ResponseText: string read FResponseText;
  end;

  TChatStreamEvent = reference to procedure(const AChunkText: string; AIsFinal: Boolean);

  TChatCompletionOptions = record
    Model: string;
    Temperature: Double;
    MaxTokens: Integer;
    constructor Create(const AModel: string; ATemperature: Double = 0.2; AMaxTokens: Integer = 512);
  end;

  ICancelToken = interface
    ['{5F9E8C73-2DF2-4D6A-9E1E-29B8BE6D5A88}']
    function IsCancelled: Boolean;
    procedure Cancel;
  end;

  TCancelToken = class(TInterfacedObject, ICancelToken)
  private
    FFlag: Integer;
  public
    function IsCancelled: Boolean;
    procedure Cancel;
  end;

  TOpenAIChatClient = class
  private
    FBaseUrl: string;
    FApiKey: string;
    FConnectTimeoutMs: Integer;
    FResponseTimeoutMs: Integer;

    procedure ApplyAuthHeaders(ARequest: IHTTPRequest);
    function BuildChatRequestBody(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
      AStream: Boolean): TJSONObject;

    function TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;
    function ExtractTextFromNonStreamingResponse(const AJsonText: string): string;

    function ShouldRetryHttpStatus(AStatus: Integer): Boolean;
    function ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): IHTTPResponse;
  public
    constructor Create(const ABaseUrl, AApiKey: string);

    property ConnectTimeoutMs: Integer read FConnectTimeoutMs write FConnectTimeoutMs;
    property ResponseTimeoutMs: Integer read FResponseTimeoutMs write FResponseTimeoutMs;

    function ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;

    function ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
      const AOnEvent: TChatStreamEvent): ICancelToken;
  end;

implementation

uses
  System.StrUtils;

{ EChatApiError }

constructor EChatApiError.Create(const Msg: string; AHttpStatus: Integer; const AResponseText: string);
begin
  inherited Create(Msg);
  FHttpStatus := AHttpStatus;
  FResponseText := AResponseText;
end;

{ TChatCompletionOptions }

constructor TChatCompletionOptions.Create(const AModel: string; ATemperature: Double; AMaxTokens: Integer);
begin
  Model := AModel;
  Temperature := ATemperature;
  MaxTokens := AMaxTokens;
end;

{ TCancelToken }

procedure TCancelToken.Cancel;
begin
  TInterlocked.Exchange(FFlag, 1);
end;

function TCancelToken.IsCancelled: Boolean;
begin
  Result := TInterlocked.CompareExchange(FFlag, 0, 0) = 1;
end;

{ TOpenAIChatClient }

constructor TOpenAIChatClient.Create(const ABaseUrl, AApiKey: string);
begin
  inherited Create;
  FBaseUrl := ABaseUrl.TrimRight(['/']);
  FApiKey := AApiKey;
  FConnectTimeoutMs := 8000;
  FResponseTimeoutMs := 120000; // Streaming: eher großzügig; Abbruch über CancelToken.
end;

procedure TOpenAIChatClient.ApplyAuthHeaders(ARequest: IHTTPRequest);
begin
  ARequest.AddHeader('Authorization', 'Bearer ' + FApiKey);
  ARequest.AddHeader('Content-Type', 'application/json');
  ARequest.AddHeader('Accept', 'application/json');
end;

function TOpenAIChatClient.BuildChatRequestBody(const AUserPrompt: string;
  const AOptions: TChatCompletionOptions; AStream: Boolean): TJSONObject;
var
  Msgs: TJSONArray;
  Msg: TJSONObject;
begin
  Result := TJSONObject.Create;
  Result.AddPair('model', AOptions.Model);
  Result.AddPair('temperature', TJSONNumber.Create(AOptions.Temperature));
  Result.AddPair('max_tokens', TJSONNumber.Create(AOptions.MaxTokens));
  Result.AddPair('stream', TJSONBool.Create(AStream));

  Msgs := TJSONArray.Create;
  Msg := TJSONObject.Create;
  Msg.AddPair('role', 'user');
  Msg.AddPair('content', AUserPrompt);
  Msgs.AddElement(Msg);
  Result.AddPair('messages', Msgs);
end;

function TOpenAIChatClient.ShouldRetryHttpStatus(AStatus: Integer): Boolean;
begin
  // Redaktionelle Einschätzung (bewusst genau einmal):
  // 429 und 5xx sind die häufigsten "vorübergehend"-Fälle; 401/403/400 sollte man nicht automatisiert retryen.
  Result := (AStatus = 429) or ((AStatus >= 500) and (AStatus <= 599));
end;

function TOpenAIChatClient.ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): IHTTPResponse;
const
  MaxAttempts = 4;
var
  Attempt: Integer;
  DelayMs: Integer;
  Resp: IHTTPResponse;
begin
  DelayMs := 250;
  for Attempt := 1 to MaxAttempts do
  begin
    try
      Resp := ADoRequest();
      if (Resp <> nil) and ShouldRetryHttpStatus(Resp.StatusCode) then
      begin
        if Attempt = MaxAttempts then
          Exit(Resp);
        Sleep(DelayMs);
        DelayMs := Min(DelayMs * 2, 4000);
        Continue;
      end;
      Exit(Resp);
    except
      on E: ENetHTTPClientException do
      begin
        // Netzwerk/TLS/Timeout: Backoff-Retry, aber begrenzt.
        if Attempt = MaxAttempts then
          raise;
        Sleep(DelayMs);
        DelayMs := Min(DelayMs * 2, 4000);
      end;
    end;
  end;
  Result := nil;
end;

function TOpenAIChatClient.TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;
var
  J: TJSONValue;
  EObj: TJSONObject;
begin
  Result := False;
  AMessage := '';

  J := TJSONObject.ParseJSONValue(AJsonText);
  try
    if (J is TJSONObject) then
    begin
      EObj := (J as TJSONObject).GetValue<TJSONObject>('error');
      if Assigned(EObj) then
      begin
        AMessage := EObj.GetValue<string>('message', '');
        Result := AMessage <> '';
      end;
    end;
  finally
    J.Free;
  end;
end;

function TOpenAIChatClient.ExtractTextFromNonStreamingResponse(const AJsonText: string): string;
var
  J: TJSONValue;
  Root: TJSONObject;
  Choices: TJSONArray;
  Choice0, Msg: TJSONObject;
begin
  Result := '';

  J := TJSONObject.ParseJSONValue(AJsonText);
  try
    if not (J is TJSONObject) then
      raise EChatApiError.Create('Unerwartete JSON-Antwort (kein Objekt).', 0, AJsonText);

    Root := J as TJSONObject;
    Choices := Root.GetValue<TJSONArray>('choices');
    if (Choices = nil) or (Choices.Count = 0) then
      raise EChatApiError.Create('Unerwartete JSON-Antwort: choices fehlt/leer.', 0, AJsonText);

    Choice0 := Choices.Items[0] as TJSONObject;
    Msg := Choice0.GetValue<TJSONObject>('message');
    if Msg = nil then
      raise EChatApiError.Create('Unerwartete JSON-Antwort: message fehlt.', 0, AJsonText);

    Result := Msg.GetValue<string>('content', '');
  finally
    J.Free;
  end;
end;

function TOpenAIChatClient.ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;
var
  Http: THTTPClient;
  Req: IHTTPRequest;
  Resp: IHTTPResponse;
  Body: TJSONObject;
  Payload: TStringStream;
  RespText, ErrMsg: string;
begin
  Http := THTTPClient.Create;
  try
    Http.ConnectionTimeout := FConnectTimeoutMs;
    Http.ResponseTimeout := FResponseTimeoutMs;

    Body := BuildChatRequestBody(AUserPrompt, AOptions, False);
    try
      Payload := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
      try
        Req := Http.GetRequest('POST', FBaseUrl + '/v1/chat/completions');
        ApplyAuthHeaders(Req);

        Resp := ExecuteWithRetry(
          function: IHTTPResponse
          begin
            Result := Http.Execute(Req, Payload);
          end);

        RespText := Resp.ContentAsString(TEncoding.UTF8);

        if (Resp.StatusCode < 200) or (Resp.StatusCode >= 300) then
        begin
          if TryExtractErrorMessage(RespText, ErrMsg) then
            raise EChatApiError.Create(ErrMsg, Resp.StatusCode, LeftStr(RespText, 8192))
          else
            raise EChatApiError.Create('HTTP-Fehler ' + Resp.StatusCode.ToString, Resp.StatusCode, LeftStr(RespText, 8192));
        end;

        Result := ExtractTextFromNonStreamingResponse(RespText);
      finally
        Payload.Free;
      end;
    finally
      Body.Free;
    end;
  finally
    Http.Free;
  end;
end;

function TOpenAIChatClient.ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
  const AOnEvent: TChatStreamEvent): ICancelToken;
var
  Token: ICancelToken;
begin
  Token := TCancelToken.Create;
  Result := Token;

  TTask.Run(
    procedure
    var
      Http: THTTPClient;
      Req: IHTTPRequest;
      Resp: IHTTPResponse;
      Body: TJSONObject;
      Payload: TStringStream;
      Reader: TStreamReader;
      Line, Data: string;
      J: TJSONValue;
      Choices, Choice0, Delta: TJSONValue;
      Chunk: string;
      ErrMsg, RespText: string;
    begin
      Http := THTTPClient.Create;
      try
        Http.ConnectionTimeout := FConnectTimeoutMs;
        Http.ResponseTimeout := FResponseTimeoutMs;

        Body := BuildChatRequestBody(AUserPrompt, AOptions, True);
        try
          Payload := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
          try
            Req := Http.GetRequest('POST', FBaseUrl + '/v1/chat/completions');
            ApplyAuthHeaders(Req);
            Req.AddHeader('Accept', 'text/event-stream');

            Resp := ExecuteWithRetry(
              function: IHTTPResponse
              begin
                Result := Http.Execute(Req, Payload);
              end);

            if (Resp.StatusCode < 200) or (Resp.StatusCode >= 300) then
            begin
              RespText := Resp.ContentAsString(TEncoding.UTF8);
              if TryExtractErrorMessage(RespText, ErrMsg) then
                ErrMsg := 'Streaming-Fehler: ' + ErrMsg
              else
                ErrMsg := 'Streaming-Start fehlgeschlagen (HTTP ' + Resp.StatusCode.ToString + ').';

              TThread.Queue(nil,
                procedure
                begin
                  AOnEvent(ErrMsg, True);
                end);
              Exit;
            end;

            Reader := TStreamReader.Create(Resp.ContentStream, TEncoding.UTF8, True, 4096, False);
            try
              while (not Reader.EndOfStream) and (not Token.IsCancelled) do
              begin
                Line := Reader.ReadLine;
                if Line = '' then
                  Continue;

                if not Line.StartsWith('data:') then
                  Continue;

                Data := Line.Substring(5).Trim;
                if SameText(Data, '[DONE]') then
                  Break;

                J := TJSONObject.ParseJSONValue(Data);
                try
                  Chunk := '';
                  if (J is TJSONObject) then
                  begin
                    Choices := (J as TJSONObject).GetValue('choices');
                    if (Choices is TJSONArray) and (TJSONArray(Choices).Count > 0) then
                    begin
                      Choice0 := TJSONArray(Choices).Items[0];
                      Delta := (Choice0 as TJSONObject).GetValue('delta');
                      if (Delta is TJSONObject) then
                        Chunk := TJSONObject(Delta).GetValue<string>('content', '');
                    end;
                  end;
                finally
                  J.Free;
                end;

                if Chunk <> '' then
                  TThread.Queue(nil,
                    procedure
                    begin
                      AOnEvent(Chunk, False);
                    end);
              end;

              TThread.Queue(nil,
                procedure
                begin
                  // final event (auch bei Cancel): UI kann Buttonzustände zurücksetzen.
                  AOnEvent('', True);
                end);
            finally
              Reader.Free;
            end;
          finally
            Payload.Free;
          end;
        finally
          Body.Free;
        end;
      finally
        Http.Free;
      end;
    end);
end;

end.

Was dieser Schnipsel absichtlich „anders“ macht

  • CancelToken statt „Thread kill“: Der Stream-Loop prüft IsCancelled. Das ist simpel, aber stabil. (Je nach Delphi-Version kann zusätzlich ein aktives Abbrechen über HTTP-Client-Mechanismen möglich sein; das Grundprinzip bleibt: kontrolliert beenden.)
  • Retry mit Statuscode-Regel: Netzwerkexceptions und 429/5xx werden mit Backoff behandelt; 401/403/400 eben nicht. Das reduziert sinnlose Wiederholungen.
  • Defensives Parsing: Jeder Cast wird abgesichert. Bei Streaming werden Chunks toleriert, die kein delta.content liefern.
  • Response-Kappung bei Fehlern: Bei Exceptions wird der Body gekürzt (z. B. 8 KB). Das ist im Betrieb oft der Unterschied zwischen „nicht reproduzierbar“ und „in 5 Minuten verstanden“.

Mehrwert: Welche Timeout-/Retry-Kombination passt wozu?

Use Case ConnectionTimeout ResponseTimeout Retry-Regel Hinweis
UI-Tool, nicht-streaming 5–10 s 30–60 s Netzfehler + 429/5xx Fortschrittsanzeige statt „harter“ kurzer Timeouts
UI-Tool, SSE-Streaming 5–10 s 90–180 s wie oben, aber sparsam Abbruch über Cancel; optional Idle-Timeout seit letztem Token
Batch/Service 5–10 s je nach SLA 429/5xx mit jitter Hier sind strukturierte Logs und Metriken Pflicht

Debugging und Betrieb: Was loggen, ohne sich selbst zu schaden?

Wenn die Anbindung in ein produktives Umfeld geht, sind diese Punkte meist entscheidender als „noch ein JSON-Feld“:

  1. Request-ID pro Aufruf (auch für UI-Aktionen). Diese ID hängt an alle Logzeilen.
  2. Statuscode + Latenz (Millisekunden) und ob Retry aktiv wurde (Attempt, Delay).
  3. Content-Type der Response (Streaming vs. JSON) – damit sehen Sie Proxy-/Gateway-Fehlkonfigurationen schneller.
  4. Redaction: Authorization-Header nie loggen; Prompt/Response nur gekürzt und nur, wenn Ihr Datenschutz- und Betriebsmodell das erlaubt.

Wenn Sie für Desktop-Tools bereits strukturierte Logs nutzen (NDJSON/JSON mit Kontext wie Request-ID), lässt sich die Chat-Integration sehr gut in dieselbe Linie ziehen. Inhaltlich passt hier ein interner Verweis auf Ihre bestehende Logging-Strategie, bevor Sie später „mehr Observability“ nachrüsten.

Grenzen und Risiken: wann der Desktop-Client der falsche Ort ist

Der gezeigte Ansatz ist bewusst ein Client-Schnipsel. Er lohnt sich für interne Tools, Support-Utilities und prozessnahe Desktop-Clients, wenn Sie Kontrolle über Deployment und Policies haben. Die Grenzen sind klar:

  • Key-Management: Wenn der API-Key im Client steckt, müssen Sie Secrets sauber schützen (und bei Kompromittierung rotieren). Oft ist ein serverseitiger Broker besser.
  • Compliance/Audit: Wenn Sie Nachvollziehbarkeit, zentrale Policies oder Mandantenfähigkeit brauchen, gehört die Anbindung typischerweise in einen eigenen REST-Service.
  • Prompt-Inhalte: Prompts können sensible Unternehmensdaten enthalten. Ohne klare Regeln für Redaction, Aufbewahrung und Zugriff ist „mal eben integrieren“ ein Risiko.
  • Streaming in instabilen Netzen: SSE ist robust, aber nicht magisch. Ohne Cancel und ohne sinnvolle Timeouts erzeugen Sie „hängt“-Tickets.

Fazit: Solide Integration ist Threading plus Betrieb, nicht nur JSON

Wer die ChatGPT API mit Delphi FMX/VCL in eine gewachsene Business-Software-Landschaft integrieren will, sollte früh in zwei Dinge investieren: erstens in ein entkoppeltes Client-Modul mit defensivem Parsing und klarer Fehlerklassifikation, zweitens in ein Betriebskonzept (Timeouts, Retry, Logging ohne Secrets, Cancel). Streaming per SSE ist dann keine Spielerei, sondern eine UX-Verbesserung, die unter realen Netzwerkbedingungen stabil bleibt.

Wenn Sie merken, dass Key-Management, Governance oder Auditierung wichtiger werden als die UI-Integration, ist das kein Scheitern des Ansatzes – es ist ein Signal, die Integrationskante in einen kontrollierten Server zu verlagern.

Für dieses Thema sind auch Bearer Token Delphi REST und Delphi Http Client Timeout Retry wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.

  • Bestand, Zielbild und technische Risiken werden zusammen bewertet.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Beitrag teilen

Diesen Beitrag direkt weitergeben

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. für Instagram bereiten wir Link und Kurztext direkt vor.

E-Mail

Instagram oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.