Net-Base Журнал

15.07.2026

ChatGPT API з Delphi у FMX/VCL: надійне підключення зі стрімінгом, повторними спробами та коректним розбором JSON

Як надійно підключити ChatGPT API до Delphi FMX/VCL: HTTP-клієнт із таймаутами та повторними спробами, SSE-стрімінг без зависань інтерфейсу, а також надійний розбір JSON для викликів інструментів і обробки помилок.

15.07.2026

Від теми журналу до практики проєкту

Відповідні сторінки послуг і технічні сторінки до публікації

Warum „ChatGPT API mit Delphi FMX/VCL“ in der Praxis nicht nur ein POST ist

Хто хоче підключити ChatGPT API mit Delphi FMX/VCL, швидко потрапляє на простий HTTP-POST. У реальних бізнес-програмних середовищах це, однак, ламається в трьох місцях: (1) Timeouts und Retries повинні бути детерміністичними, бо інакше користувач отримає «завислий» UI, (2) Streaming (Server-Sent Events, kurz SSE) часто корисний для хорошої UX, але в Delphi-потоках швидко стає схильним до помилок, та (3) JSON — це не просто «об’єкт»: повідомлення про помилки, проблеми з квотами, порожні поля або незначно змінені форми відповіді потрібно обробляти надійно.

Наведений фрагмент коду показує підхід, що однаково працює в FMX і VCL: власний, тестований клієнт, який опціонально працює у режимі nicht-streaming або streaming, коректно маршалить оновлення UI (тобто виконує їх через синхронізацію з головним потоком) і логгирує інформативно у разі помилок. Додатково він спроєктований так, щоб легко вписуватися в існуючі шарові структури (наприклад «API-Client» в шарі інтеграції, UI залишається тонким).

Architektur-Skizze: UI entkoppeln, Client testbar halten

У Delphi-проєктах з великою історією часто зустрічається «HTTP im ButtonClick». Це працює до першого інциденту. Рекомендується невеликий клієнт з такими складовими:

  • Konfiguration: Base-URL, API-Key, Modell, Timeouts.
  • Transportschicht: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (je nach Betrieb).
  • Parser: JSON-Decoding, Fehlerobjekte, Ergebnisextraktion.
  • UI-Hooks: Callback für Token-/Text-Streaming, aber ohne harte Abhängigkeit auf VCL/FMX Controls.

Таким чином інтеграцію в індивідуальне корпоративне ПЗ можна виконувати акуратно: клієнт легко повторно використовувати в сервісах, десктоп-клієнтах, адмін-інструментах або тестових стендах.

Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON

Код використовує THTTPClient (System.Net.HttpClient) і свідомо парсить мінімально з допомогою System.JSON. Для SSE читається по рядках і реагують на «data: …». Це не «WebSocket», а HTTP-Response-Stream, що безперервно постачає текстові рядки. Важливо: читання відбувається у Worker-Thread, а оновлення UI маршаляться в Main Thread.

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-токен: тут API-ключ як «Authorization: Bearer …».
// У корпоративних середовищах додатково стежте, щоб ключі не потрапляли до логів.
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));

// Мінімальна структура 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
// Поширений формат: { „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(‚Несподівана JSON-відповідь (не об“єкт).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Несподівана JSON-відповідь: відсутній/пустий 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(‚Несподівана JSON-відповідь: поле 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
// Помилки мережі/TLS/таймауту: просте повторення з експоненційним 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-помилка ‚ + 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
// Старт стрімінгу спеціально асинхронно, щоб 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
// При помилках стрімінгу вміст часто все одно JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Запуск стримінгу не вдався (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: рядки типу „data: {…}“ або „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-чанку: 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.

Навіщо цей підхід

Код вирішує три типові класи проблем, які у VCL/FMX швидко призводять до значних витрат:

  • Streaming ohne UI-Freezes: Der HTTP-Stream wird im Hintergrund gelesen; UI-Updates laufen über TThread.Queue (asynchron in den Main Thread). Das ist in FMX und VCL der robuste Standardweg.
  • Повторні спроби при помилках мережі: Bei ENetHTTPClientException wird mit Backoff erneut versucht. Das ist bewusst simpel und lässt sich später um Statuscodes (429/5xx) erweitern.
  • Стійкий JSON-розбір: Statt blind Felder zu casten, wird schrittweise geprüft. Das reduziert „Invalid type cast“-Fehler bei Sonderantworten.

Обмеження, підводні камені та варіанти

  • SSE ist kein „normales JSON“: Bei Streaming kommt eine Folge von Events, nicht eine einzelne JSON-Antwort. Deshalb ist das zeilenweise Lesen und das Erkennen von [DONE] zentral.
  • THTTPClient und Proxies/SSL: In Admin-Netzen sind TLS-Inspection und Proxy-Pflichten real. Planen Sie THTTPClient.ProxySettings und ggf. Zertifikatsthemen ein. Debugging: Statuscode/Headers immer mitloggen (ohne API-Key).
  • Timeout-Strategie: ResponseTimeout ist bei Streaming heikel: Wenn Sie zu kurz wählen, schneidet der Client lange Antworten ab. In UI-Tools ist ein längerer Timeout und ein „Abbrechen“-Button oft sinnvoller als „kurz und hart“.
  • Thread-Abbruch: Der Schnipsel zeigt keinen Cancel-Token. Für produktive Tools lohnt sich ein Abbruchmechanismus (z. B. Flag + Http.CancelAll in neueren Delphi-Versionen oder per kontrolliertem Stream-Abbruch).
  • Modell- und API-Weiterentwicklung: Die Struktur von Responses kann sich unterscheiden. Halten Sie Parser defensiv und zentral, nicht in Formularcode verteilt.

Налагодження у дорослих Delphi-Clients: що слід реально логувати

У інтеграційних проєктах перший запуск рідко провалюється через JSON, натомість — через деталі середовища. У технічний лог (файл, Eventlog, централізований Logger) на практиці слід записувати:

  • Request-ID (selbst vergeben), мітка часу, цільовий URL (ohne Secret-Querystrings).
  • HTTP-Statuscode, Content-Type, довжина відповіді, Laufzeit.
  • Обрізане Response-Body bei Fehlern (z. B. max. 4–8 KB), um Quota-/Policy-Fehler erkennen zu können.
  • Explizite Kennzeichnung von Retry-Versuchen: Attempt, Delay, Exception-Klasse.

Der API-Key gehört nie ins Log. Wenn Sie den Request-Body loggen, dann nur in Diagnose-Builds und mit Maskierung, weil Prompts durchaus personenbezogene oder geschäftliche Inhalte enthalten können.

Контекст для застарілих систем: VCL, FMX und Layer-3 Architektur

Viele Delphi-Anwendungen laufen in einer klassischen 3-Schichten-Logik („Layer-3 архітектура“: UI, Geschäftslogik, Daten/Integration). Für die ChatGPT-Anbindung ist das nützlich: Der gezeigte Client gehört in die Integrationsschicht; die Geschäftslogik entscheidet, was gefragt wird; die UI zeigt nur Verlauf und Status. Damit vermeiden Sie, dass ein späterer Wechsel (anderer Provider, On-Prem-Proxy, neue Endpunkte) die Forms „zerreißt“.

Auch für Delphi-Modernisierung ist das ein guter Einstiegspunkt: Erst einen stabilen Client, dann UI-Verbesserungen (Streaming, Abbrechen, Verlauf), dann erst „intelligentere“ Features wie strukturierte Antworten oder Tool-Aufrufe.

Fazit: Solide Basis, aber nicht jede Anwendung braucht Streaming

Чітке підключення ChatGPT API з Delphi для FMX/VCL особливо виправдане там, де важливі UI-реактивність, надійність експлуатації та можливості відлагодження: інструменти адміністрування, процесно наближені десктоп-клієнти або засоби підтримки в цифрових корпоративних рішеннях. Наведений фрагмент свідомо прагматичний: SSE-стрімінг без спеціальних бібліотек, повторні спроби лише для реальних мережевих помилок, JSON обробляється обережно.

Межі застосування: Якщо вам потрібні суворі вимоги комплаєнсу, централізована Prompt-Governance, багатоклієнтність або детальні аудиторські трейли, «клієнта на десктопі» зазвичай недостатньо. У такому випадку підключення належить винести в контрольований сервер (наприклад, власний REST-Service), який централізовано реалізує політики, логування та контроль доступу. Для багатьох інсталяцій Delphi показаний тут клієнт проте є надійною відправною точкою, яку можна поетапно перевести в більш чисту загальну архітектуру.

У професійному контексті також важливу роль відіграють Openai API у Delphi та механізми Delphi Http Client Timeout Retry, коли інтеграції, потоки даних і подальший розвиток мають працювати узгоджено.

Обговорити проєкт або завдання з модернізації з Net-Base.

Наступний крок

Якщо тема перетворюється на реальний проєкт, архітектуру, наявні системи та експлуатацію слід розглядати разом на ранньому етапі.

Ми підтримуємо не лише в окремих питаннях, а й тоді, коли з уривків вихідного коду, питань, пов’язаних із legacy, або ідей порталу має вирости надійний корпоративний проєкт.

  • Поточний стан, цільова архітектура та технічні ризики оцінюються спільно.
  • REST, доступ до даних, портали та Rollout не відсуваються на пізніший етап.
  • Ви заздалегідь бачите, який шлях є економічно та операційно життєздатним.

Поділитися дописом

Поділитися цим дописом безпосередньо

LinkedIn, X, XING, Facebook, WhatsApp та E‑Mail доступні негайно. Для Instagram ми безпосередньо готуємо посилання та короткий текст.

Електронна пошта

Instagram відкривається в новій вкладці. Посилання та короткий текст попередньо копіюються у буфер обміну.