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 iz teme nastane resničen projekt, je treba arhitekturo, obstoječe sisteme in obratovanje zgodaj 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.