Net-Base Lehti

15.07.2026

ChatGPT API ja Delphi FMX/VCL: Luotettava integrointi Streamingin, Retryn ja puhtaan JSON-parsinnan avulla

Näin liität ChatGPT API:n Delphi FMX/VCL:llä luotettavasti: HTTP-asiakas aikakatkaisuilla ja uusintayrityksillä, SSE-streamaus ilman käyttöliittymän jäätymisiä sekä luotettava JSON-jäsennys työkalukutsuille ja virhetilanteille.

15.07.2026

Lehden aiheesta projektikäytäntöön

Artikkeliin liittyvät palvelu- ja tekniikkasivut

Miksi „ChatGPT API mit Delphi FMX/VCL“ käytännössä ei ole pelkkä POST

Jos haluaa liittää ChatGPT API:n ja Delphi FMX/VCL:ään, päädyt helposti yksinkertaiseen HTTP-POSTiin. Aidossa business-ohjelmistoympäristössä tämä kompastuu kuitenkin kolmessa kohdassa: (1) aikakatkaisut ja uudelleenyritykset on määriteltävä deterministisesti, muuten käyttäjät kokevat käyttöliittymän jumiutumisen, (2) streaming (Server-Sent Events, lyhyesti SSE) on usein parempaa UX:ää varten järkevä, mutta Delphi-säikeistämisessä nopeasti virhealtis, ja (3) JSON ei ole vain ”yksi objekti”: virheilmoitukset, kiintiöongelmat, tyhjät kentät tai hieman muuttuneet vastausrakenteet on käsiteltävä robustisti.

Seuraava lähdekoodikatkelma esittelee lähestymistavan, joka toimii sekä FMX:ssä että VCL:ssä: oma, testattava client, joka voi työskennellä joko ei-streaming tai streaming-tilassa, siirtää UI-päivitykset siististi pääsäikeelle (eli suorittaa ne pääsäikeen synkronoinnin kautta) ja kirjaa virheet selkeästi. Lisäksi se on rakennettu siten, että se istuu olemassa oleviin kerrosrakenteisiin (esim. „API-Client“ integraatiokerrokseen, käyttöliittymä pysyy ohueena).

Arkkitehtuurisketsi: käyttöliittymän irrottaminen, clientin pitäminen testattavana

Pitkän historian Delphi-projekteissa näkee usein „HTTP im ButtonClick“. Se toimii kunnes tulee ensimmäinen häiriötilanne. Suositeltava ratkaisu on pieni client, jossa on:

  • Konfiguraatio: Base-URL, API-Key, malli, aikakatkaisut.
  • Siirtokerros: HTTP-Request/Response, uudelleenyritys, aikakatkaisu, Proxy-/SSL-asetukset (riippuen käytöstä).
  • Parseri: JSON-dekoodaus, virheobjektit, tulosten poiminta.
  • UI-koukut: callback token-/tekstivirtausta varten, mutta ilman tiukkaa riippuvuutta VCL/FMX-kontrolleihin.

Tällä tavalla integraatio yksilölliseen yritysohjelmistoon voidaan hoitaa siististi: client on uudelleenkäytettävissä palveluissa, työpöytäasiakasohjelmissa, ylläpitotyökaluissa tai testiharneyksiköissä.

Lähdekoodikatkelma: Delphi-Client SSE-streamingillä, aikakatkaisu/uudelleenyritys ja robusti JSON

Koodi käyttää THTTPClient (System.Net.HttpClient) -luokkaa ja jäsentää tarkoituksellisesti vain minimalistisesti System.JSON:lla. SSE:ssä luetaan rivikohtaisesti ja reagoidaan „data: …“ -riveihin. Tämä ei ole „WebSocket“, vaan HTTP-vastausvirta, joka toimittaa jatkuvasti tekstirivejä. Tärkeää: luemme työssä-säikeessä ja marshalaamme UI-päivitykset pääsäikeeseen.

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.

Mihin lähestymistapa sopii

Koodi ratkaisee kolme tyypillistä ongelmaryhmää, jotka VCL/FMX:ssä voivat nopeasti käydä kalliiksi:

  • Streaming ilman käyttöliittymän jäätymisiä: HTTP-streami luetaan taustalla; käyttöliittymäpäivitykset ajetaan TThread.Queue-kutsujen kautta (asynkronisesti pääsäikeeseen). Tämä on FMX:ssä ja VCL:ssä vakiintunut, luotettava tapa.
  • Uudelleenyritys verkkovirheissä: Kun esiintyy ENetHTTPClientException, yritetään uudelleen viiveellä (backoff). Tämä on tietoisesti yksinkertaista ja myöhemmin laajennettavissa tilakoodeilla (429/5xx).
  • Kestävä JSON-jäsennys: Sen sijaan, että kenttiä muunnettaisiin sokeasti, tarkistetaan vaiheittain. Tämä vähentää „Invalid type cast“ -virheitä erikoisvastauksissa.

Reunaehdot, sudenkuopat ja vaihtoehdot

  • SSE ei ole „tavallinen JSON“: Streamingissä tulee tapahtumasarja, ei yksittäistä JSON-vastausta. Siksi rivikohtainen lukeminen ja [DONE]:n tunnistus ovat keskeisiä.
  • THTTPClient ja proxyt/SSL: Admin-verkostoissa TLS-tarkastus ja pakolliset proxyt ovat todellisuutta. Suunnittele THTTPClient.ProxySettings ja tarvittaessa sertifikaattiasiat etukäteen. Debuggaus: kirjaa aina statuskoodi ja headerit (ilman API-avainta).
  • Aikakatkaisustrategia: ResponseTimeout on streaming-tilanteissa herkkä: jos asetat liian lyhyen, asiakasohjelma katkaisee pitkät vastaukset. UI-työkaluissa pidempi timeout ja „Peruuta“-painike ovat usein järkevämpiä kuin „lyhyt ja kova“.
  • Säikeen keskeytys: Esimerkki ei näytä Cancel-tokenia. Tuotantotyökaluihin kannattaa lisätä keskeytysmekanismi (esim. lippu + Http.CancelAll uudemmissa Delphi-versioissa tai kontrolloitu streamin katkaisu).
  • Mallin ja API:n kehittyminen: Vastausten rakenne voi muuttua. Pidä parseri defensiivisenä ja keskitettynä, älä hajauta sitä lomakekoodiin.

Debuggaus olemassa olevissa Delphi-asiakkaissa: was Sie wirklich loggen sollten

Integraatioprojekteissa ensimmäinen käyttöönotto epäonnistuu harvoin JSON:in vuoksi, ja useammin ympäristöön liittyvien yksityiskohtien takia. Tekniseen lokiin (tiedosto, Eventlog, keskitetty logger) käytännössä kuuluu:

  • Request-ID (itse määritetty), aikaleima, kohde-URL (ilman salaisia query-parametreja).
  • HTTP-statuskoodi, Content-Type, vastauspituus, käsittelyaika.
  • Lyhennetty response-body virhetilanteissa (esim. max. 4–8 KB), jotta quota-/politiikkavirheet voidaan tunnistaa.
  • Selkeä merkintä uudelleenyrityksistä: yrityskerta (Attempt), viive (Delay), poikkeusluokka (Exception-Klasse).

API-avainta ei koskaan tule kirjoittaa lokiin. Jos lokitat request-bodyn, tee se vain diagnostisissa buildeissa ja maskattuna, koska promptit voivat sisältää henkilötietoja tai liiketoimintakriittistä sisältöä.

Sijoitus legacy-tilanteisiin: VCL, FMX und Layer-3-Arkkitehtuuri

Monet Delphi-sovellukset toimivat klassisessa 3-kerroslogiikassa („Layer-3 Architektur“: UI, liiketoimintalogiikka, data/integrointi). ChatGPT-liitännälle tämä on hyödyllistä: esitetty client kuuluu integraatiokerrokseen; liiketoimintalogiikka päättää, mitä kysytään; UI näyttää vain historian ja tilan. Näin vältetään, että myöhempi muutos (toinen tarjoaja, On-Prem-Proxy, uudet päätepisteet) rikkoo lomakkeet.

Myös Delphi-modernisointiin tämä on hyvä lähtökohta: ensin vakaa client, sitten UI-parannukset (Streaming, Peruuta, Historia), ja vasta sen jälkeen „älykkäämmät“ ominaisuudet kuten strukturoidut vastaukset tai työkalukutsut.

Yhteenveto: Vankka perusta, mutta kaikki sovellukset eivät tarvitse streamingia

ChatGPT-rajapinnan liittäminen Delphi FMX/VCL:llä oikein kannattaa erityisesti tilanteissa, joissa käyttöliittymän reaktiokyky, toimintavarmuus ja virheenkorjattavuus ovat tärkeitä: ylläpitotyökalut, prosessiläheiset työpöytäasiakkaat tai tukityökalut digitaalisissa yritysratkaisuissa. Näytetty koodinpätkä on tietoisesti pragmaattinen: SSE-striimaus ilman erikoiskirjastoja, uudelleenkokeilu vain todellisissa verkkovirheissä, JSON parsitaan defensiivisesti.

Käyttörajoitukset: Jos tarvitsette tiukkoja noudattamisvaatimuksia (compliance), keskitettyä prompt-hallintaa, monen asiakkaan tukea tai yksityiskohtaisia audit-lokeja, pelkkä „työpöytäasiakas“ ei yleensä riitä. Tällöin liityntä kuuluu tyypillisesti kontrolloituun palvelimeen (esim. oma REST-service), joka toteuttaa käytännöt, lokituksen ja käyttöoikeuksien hallinnan keskitetysti. Monille Delphi-asennuksille tässä esitetty client kuitenkin muodostaa luotettavan lähtökohdan, jonka voi vaiheittain siirtää osaksi selkeämpää kokonaisarkkitehtuuria.

Ammattimaisessa ympäristössä myös Openai API In Delphi ja Delphi Http Client Timeout Retry näyttelevät merkittävää roolia, kun integraatioiden, tietovirtojen ja jatkokehityksen on toimittava saumattomasti yhdessä.

Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.

Nächster Schritt

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

Emme tue pelkästään yksittäiskysymyksissä, vaan myös silloin, kun lähdekoodipalasista, legacy-aiheista tai portaali-ideoista halutaan muodostaa luotettava yrityshanke.

  • Nykytila, tavoitetila ja tekniset riskit arvioidaan yhdessä.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Jaa artikkeli

Jaa tämä viesti suoraan

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

Sähköposti

Instagram avautuu uuteen välilehteen. Linkki ja lyhyt teksti kopioidaan ensin leikepöydälle.