Net-Base Revija

15.07.2026

ChatGPT API z Delphi za FMX/VCL: robustna integracija s streamingom, retry in čistim JSON-parsingom

Tako robustno povežete ChatGPT API z Delphi FMX/VCL: HTTP-odjemalec z timeouti in ponovitvami, SSE-streaming brez zamrznitev uporabniškega vmesnika ter zanesljivo JSON-parsanje za klice orodij in obravnavo napak.

15.07.2026

Od teme v reviji do projektne prakse

Ustrezne strani storitev in tehnični opisi k prispevku

Zakaj „ChatGPT API z Delphi FMX/VCL“ v praksi ni le POST

Kdor želi povezati ChatGPT API z Delphi FMX/VCL, hitro pristane pri preprostem HTTP-POSTu. V resničnih poslovnih programskih okoljih pa se to izkaže za problematično na treh mestih: (1) Timeouti in retriji morajo biti deterministični, saj uporabniki sicer občutijo „zamrznjen“ vmesnik, (2) Streaming (Server-Sent Events, kratko SSE) je za dobro UX pogosto smiselno, vendar je pri Delphi-threadingu hitro nagnjen k napakam, in (3) JSON ni zgolj „en objekt“: napake, težave s kvotami, prazna polja ali rahlo spremenjene oblike odgovorov morajo biti robustno obravnavane.

Naslednji izsek iz kode prikazuje pristop, ki deluje enako v FMX in VCL: lasten, testabilen odjemalec, ki deluje izbirno kot brez streaminga ali streaming, urejeno prenese UI-posodobitve (tj. izvede jih preko sinhronizacije glavne niti) in ob napakah zapisuje informativne loge. Poleg tega je zasnovan tako, da se vgradi v obstoječe slojne strukture (npr. „API-Client“ v integracijski sloj, UI ostane tanek).

Skica arhitekture: UI ločiti, ohraniti odjemalca testabilnega

V Delphi-projektih z dolgo zgodovino pogosto najdete „HTTP v ButtonClick“. To deluje do prvega incidenta. Priporočljiv je majhen odjemalec z:

  • Konfiguracija: Base-URL, API-Key, model, Timeouts.
  • Transportna plast: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (odvisno od okolja).
  • Parser: JSON-Decoding, objekt napak, ekstrakcija rezultatov.
  • UI-Hooks: Callback za token-/tekstovno streaming, vendar brez stroge odvisnosti od VCL/FMX kontrol.

Tako se integracija v individualno poslovno programsko opremo lahko izvede čisto: odjemalec je mogoče ponovno uporabiti v servisih, namiznih odjemalcih, administrativnih orodjih ali test-harnessih.

Izsek kode: Delphi-odjemalec z SSE-pretakanjem, Timeout/Retry in robustnim JSON-om

Koda uporablja THTTPClient (System.Net.HttpClient) in zavestno samo minimalno parsira z System.JSON. Za SSE se bere po vrsticah in reagira na „data: …“. To ni „WebSocket“, temveč HTTP-Response-Stream, ki neprekinjeno dostavlja tekstovne vrstice. Pomembno: beremo v worker-niti in prenašamo UI-posodobitve v glavno nit.

Delphi
unit Net-Base.OpenAI.ChatClient;

interface

uses
  System.SysUtils, System.Classes, System.Net.URLClient, System.Net.HttpClient,
  System.Net.HttpClientComponent, 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;

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

    function BuildChatRequestBody(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
      AStream: Boolean): TJSONObject;

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

    procedure ApplyAuthHeaders(ARequest: IHTTPRequest);

    function ExecuteWithRetry(const ADoRequest: TFunc): 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;

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

implementation

{ 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;

{ TOpenAIChatClient }

constructor TOpenAIChatClient.Create(const ABaseUrl, AApiKey: string);
begin
  inherited Create;
  FBaseUrl := ABaseUrl.TrimRight(['/']);
  FApiKey := AApiKey;
  FConnectTimeoutMs := 8000;
  FResponseTimeoutMs := 60000;
end;

procedure TOpenAIChatClient.ApplyAuthHeaders(ARequest: IHTTPRequest);
begin
  // Bearer Token: hier API-Key als „Authorization: Bearer …“.
  // In Unternehmensumgebungen zusätzlich darauf achten, dass Keys nicht ins Log geraten.
  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));

  // Minimaler Messages-Aufbau (Chat Completions): role/content.
  Msgs := TJSONArray.Create;
  Msg := TJSONObject.Create;
  Msg.AddPair('role', 'user');
  Msg.AddPair('content', AUserPrompt);
  Msgs.AddElement(Msg);
  Result.AddPair('messages', Msgs);
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
      // Häufige Form: { "error": { "message": "...", "type": "..." } }
      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: TJSONObject;
  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;
    // Chat Completions: choices[0].message.content
    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.ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): IHTTPResponse;
const
  MaxAttempts = 3;
var
  Attempt: Integer;
  DelayMs: Integer;
begin
  DelayMs := 350;
  for Attempt := 1 to MaxAttempts do
  begin
    try
      Exit(ADoRequest());
    except
      on E: ENetHTTPClientException do
      begin
        // Netzwerk-/TLS-/Timeout-Fehler: einfacher Retry mit Backoff.
        if Attempt = MaxAttempts then
          raise;
        Sleep(DelayMs);
        DelayMs := DelayMs * 2;
      end;
    end;
  end;
  Result := nil;
end;

function TOpenAIChatClient.ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;
var
  Http: THTTPClient;
  Req: IHTTPRequest;
  Resp: IHTTPResponse;
  Body: TJSONObject;
  Payload: TStringStream;
  RespText: string;
  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, RespText)
          else
            raise EChatApiError.Create('HTTP-Fehler ' + Resp.StatusCode.ToString, Resp.StatusCode, RespText);
        end;

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

procedure TOpenAIChatClient.ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
  const AOnEvent: TChatStreamEvent);
var
  Task: ITask;
begin
  // Streaming bewusst asynchron starten, damit FMX/VCL nicht blockiert.
  Task := TTask.Run(
    procedure
    var
      Http: THTTPClient;
      Req: IHTTPRequest;
      Resp: IHTTPResponse;
      Body: TJSONObject;
      Payload: TStringStream;
      Stream: TStream;
      Reader: TStreamReader;
      Line, Data: string;
      J: TJSONValue;
      Delta, Choice0, Choices: TJSONValue;
      ContentChunk: 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
              // Bei Streaming-Fehlern ist Content oft trotzdem JSON.
              TThread.Queue(nil,
                procedure
                begin
                  AOnEvent('Streaming-Start fehlgeschlagen (HTTP ' + Resp.StatusCode.ToString + ').', True);
                end);
              Exit;
            end;

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

                // SSE-Format: Zeilen wie "data: {...}" oder "data: [DONE]"
                if Line.StartsWith('data:') then
                begin
                  Data := Line.Substring(5).Trim;
                  if SameText(Data, '[DONE]') then
                  begin
                    TThread.Queue(nil,
                      procedure
                      begin
                        AOnEvent('', True);
                      end);
                    Break;
                  end;

                  // JSON-Chunk auswerten: choices[0].delta.content
                  J := TJSONObject.ParseJSONValue(Data);
                  try
                    ContentChunk := '';
                    if J <> nil 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
                          ContentChunk := TJSONObject(Delta).GetValue<string>('content', '');
                      end;
                    end;
                  finally
                    J.Free;
                  end;

                  if ContentChunk <> '' then
                    TThread.Queue(nil,
                      procedure
                      begin
                        AOnEvent(ContentChunk, False);
                      end);
                end;
              end;
            finally
              Reader.Free;
            end;
          finally
            Payload.Free;
          end;
        finally
          Body.Free;
        end;
      finally
        Http.Free;
      end;
    end);
end;

end.

Za kaj je pristop dober

Koda rešuje tri tipične razredne težave, ki se v VCL/FMX hitro podražijo:

  • Streaming brez zamrznitev UI: HTTP-stream se bere v ozadju; posodobitve UI potekajo preko TThread.Queue (asinkrono v glavnem niti). To je v FMX in VCL robustna standardna pot.
  • Retry za mrežne napake: Pri ENetHTTPClientException se z backoff mehanizmom poizkusi znova. Namenoma je preprosto in se kasneje lahko razširi z obravnavo statusnih kod (429/5xx).
  • Robustno JSON-parsanje: Namesto slepega pretvarjanja polj se preverja korak za korakom. To zmanjša napake »Invalid type cast« pri posebnih odgovorih.

Omejitve, pastmi in variante

  • SSE ni »navaden JSON«: Pri streamingu pride zaporedje dogodkov, ne en sam JSON-odgovor. Zato je branje po vrsticah in prepoznavanje [DONE] ključno.
  • THTTPClient in proxyji/SSL: V administrativnih omrežjih so TLS-inspekcija in obvezni proxyji realnost. Načrtujte THTTPClient.ProxySettings in po potrebi vprašanja s certifikati. Za razhroščevanje: statusno kodo/glave vedno beležite (brez API-ključa).
  • Strategija timeoutov: ResponseTimeout je pri streamingu občutljiv: če je prekratek, klient odreza dolge odgovore. V UI-orodjih je daljši timeout in gumb »Prekliči« pogosto bolj smiseln kot »kratek in trd«.
  • Prekinitev niti: Prikazani primer ne vsebuje cancel-tokena. Za produkcijske orodje se izplača mehanizem prekinitve (npr. flag + Http.CancelAll v novejših Delphi-verzijah ali z nadzorovanim prekinjanjem streama).
  • Razvoj modela in API: Struktura odgovorov se lahko razlikuje. Držite parser defenzivno in centralno, ne v kodi obrazcev razpršeno.

Razhroščevanje v zraslih Delphi-klientih: Kaj bi res morali beležiti

V integracijskih projektih prva vzpostavitev redko pade na JSON, temveč na podrobnosti okolja. V tehnični log (datoteka, Eventlog, centralni logger) je v praksi smiselno zapisovati:

  • Request-ID (dodeljen samostojno), časovni žig, ciljna URL (brez secret-querystringov).
  • HTTP-statusna koda, Content-Type, dolžina odgovora, čas izvajanja.
  • Skrajšan response-body pri napakah (npr. maks. 4–8 KB), da je mogoče prepoznati quota-/policy-napake.
  • Eksplicitna označba poskusov ponovnega poizkusa: Attempt, Delay, razred izjeme.

API-key nikoli ne sodi v log. Če beležite request-body, to delajte le v diagnostičnih buildih in z maskiranjem, saj lahko prompti vsebujejo osebne ali poslovne podatke.

Položaj v legacy-situacijah: VCL, FMX in Layer-3 arhitektura

Veliko Delphi-aplikacij teče v klasični 3-slojni logiki („Layer-3 arhitektura„: UI, poslovna logika, podatki/integracija). Za ChatGPT-povezavo je to koristno: prikazani klient spada v integracijski sloj; poslovna logika odloča, kaj se vpraša; UI prikazuje le potek in status. Tako se izognete, da kasnejša menjava (drug ponudnik, on-prem proxy, novi endpointi) »raztrga« forme.

Tudi za modernizacijo Delphi je to dober izhodiščni korak: najprej stabilen klient, nato izboljšave UI (streaming, preklic, zgodovina), šele nato »inteligentnejše« funkcije kot strukturirani odgovori ali klici orodij.

Zaključek: Trdna osnova, vendar ne vsaka aplikacija potrebuje streaming

Vredno je jasno povezati ChatGPT API z Delphi FMX/VCL, zlasti tam, kjer sta odzivnost uporabniškega vmesnika, zanesljivost delovanja in razhroščevanje pomembni: orodja za skrbnike, procesno blizu namizni odjemalci ali orodja za podporo v digitalnih poslovnih rešitvah. Prikazan izsek je namerno pragmatičen: SSE-Streaming brez specialnih knjižnic, ponovni poskusi le za resnične omrežne napake, JSON varno razčlenjen.

Omejitve uporabe: Če potrebujete stroge zahteve skladnosti, centralno Prompt-Governance, večnajemniško podporo ali podrobne revizijske sledi, ‚odjemalec na namizju‘ običajno ne zadošča. V tem primeru spada povezava tipično v kontroliran strežnik (npr. lasten REST-Service), ki centralno izvaja politike, beleženje in nadzor dostopa. Za mnoge Delphi-namestitve je prikazani odjemalec vendar zanesljiva izhodiščna točka, ki jo je mogoče postopoma prenesti v čistejšo celovito arhitekturo.

V strokovnem okolju imajo tudi Openai API v Delphi in Delphi Http Client Timeout Retry pomembno vlogo, ko morajo integracije, podatkovni tokovi in nadaljnji razvoj urejeno sodelovati.

Projekt ali modernizacijski načrt z Net-Base obravnavati.

Naslednji korak

Ko se tema spremeni v realen projekt, je treba arhitekturo, obstoječe stanje in obratovanje že v zgodnji fazi obravnavati skupaj.

Ne podpiramo le pri posameznih vprašanjih, ampak tudi takrat, ko iz izrezkov izvorne kode, legacy-tem ali idej za portale nastane zanesljiv podjetniški projekt.

  • Obstoječe stanje, ciljno stanje in tehnična tveganja se ocenjujejo skupaj.
  • REST, dostop do podatkov, portali in Rollout ne bodo prestavljeni v kasnejše faze.
  • Že zgodaj vidite, katera pot je ekonomsko in operativno vzdržna.

Deli objavo

Deli ta prispevek neposredno

LinkedIn, X, XING, Facebook, WhatsApp in e-pošta so takoj na voljo. Za Instagram pripravljamo povezavo in kratek tekst.

E-pošta

Instagram se odpre v novem zavihku. Povezava in kratek opis se pred tem kopirata v odložišče.