Net-Base Magazyn

15.07.2026

ChatGPT API z Delphi FMX/VCL: stabilne podłączenie ze streamingiem, ponawianiem żądań i poprawnym parsowaniem JSON

Jak niezawodnie zintegrować ChatGPT API z Delphi FMX/VCL: klient HTTP z timeoutami i ponawianiem żądań, SSE-streaming bez zamrożeń interfejsu użytkownika oraz odporne parsowanie JSON dla wywołań narzędzi i obsługi błędów.

15.07.2026

Od tematu magazynowego do praktyki projektowej

Pasujące strony usługowe i techniczne do artykułu

Dlaczego „ChatGPT API z Delphi FMX/VCL” w praktyce to nie tylko POST

Kto chce podłączyć ChatGPT API z Delphi FMX/VCL, szybko ląduje przy prostym HTTP-POST. W rzeczywistych środowiskach oprogramowania biznesowego takie podejście zawodzI jednak w trzech miejscach: (1) limity czasu i ponawiania żądań muszą być deterministyczne, bo inaczej użytkownicy doświadczą „zawieszonego” UI, (2) streaming (Server-Sent Events, w skrócie SSE) często poprawia UX, ale w wielowątkowości Delphi szybko staje się podatny na błędy, oraz (3) JSON to nie tylko „jeden obiekt”: komunikaty o błędach, problemy z limitami, puste pola lub nieznacznie zmienione formaty odpowiedzi trzeba obsługiwać w sposób odporny.

Poniższy fragment źródłowy pokazuje podejście działające zarówno w FMX, jak i VCL: własny, testowalny klient, który opcjonalnie pracuje w trybie nie-streaming lub streaming, poprawnie marshaluje aktualizacje UI (czyli wykonuje je przez synchronizację z głównym wątkiem) i loguje błędy w sposób merytoryczny. Przy okazji jest zbudowany tak, żeby wpasować się w istniejącą strukturę warstwową (np. „API-Client” w warstwie integracji, UI pozostaje cienkie).

Szkic architektury: oddzielenie UI, utrzymanie klienta testowalnym

W projektach Delphi z długą historią często spotyka się „HTTP w ButtonClick”. To działa do pierwszego incydentu. Zalecane jest małe rozwiązanie-klient z:

  • Konfiguracja: Base-URL, API-Key, model, limity czasu.
  • Warstwa transportowa: HTTP-Request/Response, Retry, Timeout, opcje Proxy/SSL (w zależności od środowiska).
  • Parser: dekodowanie JSON, obiekty błędów, ekstrakcja wyników.
  • UI-Hooks: callback dla token-/text-streamingu, ale bez silnej zależności od kontrolek VCL/FMX.

Dzięki temu integracja z indywidualnym oprogramowaniem firmowym może być prowadzona porządnie: klient da się ponownie wykorzystać w usługach, aplikacjach desktopowych, narzędziach administracyjnych lub test-harnessach.

Fragment źródłowy: Delphi-Client z SSE-Streamingiem, Timeout/Retry i odpornym JSON

Kod wykorzystuje THTTPClient (System.Net.HttpClient) i pars(t) świadomie tylko w sposób minimalistyczny z System.JSON. Dla SSE czytane jest wiersz po wierszu i reaguje się na „data: …”. To nie jest „WebSocket”, lecz strumień odpowiedzi HTTP, który dostarcza ciągłe linie tekstu. Ważne: czytamy w wątku roboczym i przekazujemy aktualizacje UI do wykonania w głównym wątku.

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: tutaj API-Key jako „Authorization: Bearer …“.
// W środowiskach korporacyjnych dodatkowo zadbać, aby klucze nie trafiły do logów.
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));

// Minimalna struktura messages (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
// Częsty format: { „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(‚Nieoczekiwana odpowiedź JSON (brak obiektu).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Nieoczekiwana odpowiedź JSON: brak lub puste pole „choices“.‘, 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(‚Nieoczekiwana odpowiedź JSON: brak pola „message“.‘, 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
// Błędy sieciowe/TLS/timeout: proste ponowienie z 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(‚Błąd HTTP ‚ + 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
// Strumieniowanie uruchamiane świadomie asynchronicznie, aby nie blokować FMX/VCL.
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
// Przy błędach streamingu zawartość często i tak jest JSON-em.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Start streamingu nie powiódł się (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;

// Format SSE: linie typu „data: {…}“ lub „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;

// Analiza fragmentu JSON: 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.

Do czego to podejście służy

Kod rozwiązuje trzy typowe klasy problemów, które w VCL/FMX szybko stają się kosztowne:

  • Strumieniowanie bez zacięć UI: Strumień HTTP jest odczytywany w tle; aktualizacje UI wykonywane są przez TThread.Queue (asynchronicznie do wątku głównego). To jest w FMX i VCL solidny standardowy sposób.
  • Ponawianie przy błędach sieciowych: W przypadku ENetHTTPClientException podejmowana jest ponowna próba z backoffem. To jest celowo proste i można to później rozszerzyć o kody statusu (429/5xx).
  • Odporne parsowanie JSON: Zamiast ślepo rzutować pola, sprawdza się je stopniowo. To zmniejsza błędy „Invalid type cast“ przy specjalnych odpowiedziach.

Randbedingungen, Stolperfallen und Varianten

  • SSE ist kein „normales JSON“: Przy strumieniowaniu przychodzi ciąg zdarzeń, a nie pojedyncza odpowiedź JSON. Dlatego czytanie linia po linii i rozpoznawanie [DONE] jest kluczowe.
  • THTTPClient und Proxies/SSL: W sieciach administracyjnych inspekcja TLS i obowiązki dotyczące proxy są realnym wymogiem. Zaplanuj THTTPClient.ProxySettings i ewentualne kwestie związane z certyfikatami. Debugowanie: zawsze loguj kod statusu i nagłówki (bez API-Key).
  • Timeout-Strategie: ResponseTimeout jest w przypadku strumieniowania wrażliwy: jeśli ustawisz go zbyt krótko, klient obetnie długie odpowiedzi. W narzędziach z interfejsem użytkownika dłuższy timeout i przycisk „Anuluj“ są często lepsze niż podejście „krótko i ostro“.
  • Thread-Abbruch: Fragment nie pokazuje tokenu anulowania. W produkcyjnych narzędziach warto wprowadzić mechanizm przerywania (np. flaga + Http.CancelAll w nowszych Delphi-wersjach lub przez kontrolowane przerwanie strumienia).
  • Modell- und API-Weiterentwicklung: Struktura odpowiedzi może się zmieniać. Trzymaj parser defensywny i scentralizowany, a nie rozproszony w kodzie formularzy.

Debugging in gewachsenen Delphi-Clients: Was Sie wirklich loggen sollten

W projektach integracyjnych pierwsze uruchomienie rzadko zawodzi z powodu JSON, a częściej z powodu szczegółów środowiska. Do technicznego logu (plik, Eventlog, centralny logger) w praktyce powinno trafiać:

  • Request-ID (nadawana przez siebie), znacznik czasu, docelowy URL (bez tajnych parametrów w querystringu).
  • Kod statusu HTTP, Content-Type, długość odpowiedzi, czas wykonania.
  • Skrócony Response-Body w przypadku błędów (np. max. 4–8 KB), aby można było rozpoznać błędy związane z kwotami/politykami.
  • Wyraźne oznaczenie prób ponowienia: numer próby, opóźnienie, klasa wyjątku.

API-Key nigdy nie powinien być zapisywany w logu. Jeśli logujesz Request-Body, rób to tylko w buildach diagnostycznych i z maskowaniem, ponieważ prompty mogą zawierać dane osobowe lub informacje biznesowe.

Einordnung für Legacy-Situationen: VCL, FMX und Layer-3 Architektur

Wiele aplikacji Delphi działa w klasycznej logice trójwarstwowej („Layer-3 architektura„: UI, logika biznesowa, dane/integracja). Dla integracji z ChatGPT to jest praktyczne: pokazywany klient należy do warstwy integracyjnej; logika biznesowa decyduje, co jest pytane; interfejs użytkownika pokazuje jedynie historię i status. Dzięki temu unika się sytuacji, w której późniejsza zmiana (inny provider, On-Prem-Proxy, nowe endpointy) rozbije formularze.

Również przy modernizacji Delphi to dobry punkt wejścia: najpierw stabilny klient, potem ulepszenia UI (strumieniowanie, anulowanie, historia), a dopiero potem „inteligentniejsze“ funkcje, takie jak strukturyzowane odpowiedzi czy wywołania narzędzi.

Fazit: Solide Basis, aber nicht jede Anwendung braucht Streaming

Opłaca się starannie zintegrować API ChatGPT z Delphi FMX/VCL zwłaszcza tam, gdzie istotna jest responsywność interfejsu, bezpieczeństwo operacyjne i możliwość debugowania: narzędzia administracyjne, procesowo zorientowane klienty desktopowe lub narzędzia wsparcia w cyfrowych rozwiązaniach dla przedsiębiorstw. Pokazany fragment kodu jest celowo pragmatyczny: SSE-Streaming bez specjalnych bibliotek, ponawianie żądań tylko przy rzeczywistych błędach sieciowych, JSON parsowany defensywnie.

Granice zastosowań: Jeśli potrzebujecie Państwo rygorystycznych wymogów zgodności, centralnej kontroli promptów, wielodostępności lub szczegółowych ścieżek audytu, „jeden klient na pulpicie” zwykle nie wystarczy. W takim przypadku integracja typowo powinna zostać umieszczona na kontrolowanym serwerze (np. własny serwis REST), który centralnie wdraża polityki, logowanie i kontrolę dostępu. Dla wielu instalacji Delphi jednak pokazany tutaj klient stanowi solidny punkt wyjścia, który można stopniowo przenieść do bardziej uporządkowanej architektury całościowej.

W kontekście merytorycznym istotną rolę odgrywają także Openai API w Delphi oraz Delphi Http Client Timeout Retry, gdy integracje, przepływy danych i dalszy rozwój muszą ściśle współgrać.

Omówić projekt lub przedsięwzięcie modernizacyjne z Net-Base.

Następny krok

Gdy z tematu powstanie rzeczywisty projekt, architekturę, istniejący stan i eksploatację należy wcześnie rozpatrywać wspólnie.

Wspieramy nie tylko w pojedynczych zagadnieniach, lecz także wtedy, gdy z fragmentów kodu źródłowego, kwestii związanych z systemami legacy lub koncepcji portalu ma powstać solidny projekt dla przedsiębiorstwa.

  • Stan istniejący, obraz docelowy i ryzyka techniczne są oceniane łącznie.
  • REST, dostęp do danych, portale i wdrożenie nie są odraczane na późniejsze etapy.
  • Wcześnie widzą Państwo, która ścieżka jest ekonomicznie i operacyjnie wykonalna.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e-mail są natychmiast dostępne. Dla Instagramu niezwłocznie przygotowujemy link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.