Net-Base Магазин

15.07.2026

ChatGPT API са Delphi FMX/VCL: Робусно повезивање са стримовањем, поновним покушајима и чистим JSON-парсирањем

Како да интегришете ChatGPT API са Delphi у FMX/VCL робустно: HTTP клијент са timeout‑има и retry‑ом, SSE‑стриминг без замрзавања UI‑а, као и робустно JSON‑парсирање за позиве алата и случајеве грешака.

15.07.2026

Од теме часописа до пројектне праксе

Одговарајуће странице услуга и техничке странице за чланак

Зашто „ChatGPT API mit Delphi FMX/VCL“ у пракси није само POST

Ко жели да повежe ChatGPT API mit Delphi FMX/VCL, често заврши на једноставном HTTP-POST-у. У реалним бизнис-софтувер окружењима то међутим пукне на три места: (1) Timeouts и Retries морају бити детерминистички, јер иначе корисници виде „запео“ UI, (2) Streaming (Server-Sent Events, укратко SSE) је за добру UX често користан, али у Delphi-threadingu брзо постаје склони грешкама, и (3) JSON није само „један објекат“: поруке о грешкама, проблеми са квотама, празна поља или благо измењени облици одговора морају бити руковањи робусно.

Следећи исечак извoрног кода показује приступ који ради у FMX и VCL подједнако: посебан, тестабилан клијент који по избору ради не-стриминг или стриминг, који UI-ознаке пажљиво маршал (то јест извршава синхронизацију са Main-Thread-ом) и који при грешкама довољно информативно лог-ује. Уз то, он је конструисан да се чисто уклопи у постојеће слојеве (нпр. „API-Client“ у интеграционом слоју, UI остаје танак).

Архитектонска скица: одвојити UI, задржати клијент тестабилним

У Delphi пројектима са дугом историјом често нађете „HTTP у ButtonClick-у“. То ради до првог инцидента. Препоручљиво је имати мали клијент са:

  • Konfiguration: Base-URL, API-Key, Modell, Timeouts.
  • Transportschicht: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-опције (у зависности од окружења).
  • Parser: JSON-декодирање, објекти грешака, екстракција резултата.
  • UI-Hooks: позив за токен/текст-стриминг, али без чврсте зависности од VCL/FMX контрола.

Тако се интеграција у индивидуални корпоративни софтвер може чисто и поуздано одржавати: клијент се може реупотребити у сервисима, десктоп-клијентима, админ-алатима или тест-харнессима.

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-ажурирања у главну нит.

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/timeout грешке: једноставан поновни покушај са 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 брзо постају скупа:

  • Стриминг без замрзавања UI: HTTP-стрим се чита у позадини; ажурирања корисничког интерфејса се обављају преко TThread.Queue (асинхроно у главну нит). То је у FMX и VCL поуздан стандардни приступ.
  • Поновни покушаји при мрежним грешкама: При ENetHTTPClientException се покушава поново уз Backoff. То је намерно једноставно и може се касније проширити за статус кодове (429/5xx).
  • Робустно JSON-парсирање: Уместо слепог кастовања поља, врши се проверa корак по корак. То смањује „Invalid type cast“ грешке при специфичним одговорима.

Ограничења, замке и варијанте

  • SSE ist kein „normales JSON“: При стримингу долази низ догађаја, не један JSON-одговор. Због тога је читање по редовима и препознавање [DONE] кључно.
  • THTTPClient und Proxies/SSL: У админ мрежама су TLS- инспекција и обавезе проксија реалност. Планирајте THTTPClient.ProxySettings и евентуално питања сертификата. Дебаговање: увек бележите статусни код/заглавља (без API-Key).
  • Timeout-Strategie: ResponseTimeout је осетљив при стримингу: ако изаберете прениски, клијент ће пресецати дуге одговоре. У UI-алатима често је прикладнији дужи тајмаут и дугме „Откажи“ него „краће и оштро“.
  • Прекидање нити: Примерак не приказује Cancel-Token. За продуктивне алате исплати се механизам за прекид (нпр. флаг + Http.CancelAll у новијим Delphi-верзијама или контролисано прекидање стрима).
  • Развој модела и API-ја: Структура одговора се може разликовати. Парсере реализујте дефанзивно и централизовано, не распршено у коду форма.

Дебаговање у постојећим Delphi клијентима: Шта заиста треба да бележите

У интеграционим пројектима прво покретање ретко пропада због JSON-а, већ због детаља окружења. У технички лог (фајл, Eventlog, централни логер) у пракси треба да иду:

  • Request-ID (самостално додељена), временска ознака, циљни URL (без тајних query-стринг параметара).
  • HTTP-статусни код, Content-Type, дужина одговора, време извршавања.
  • Скраћен Response-Body при грешкама (нпр. макс. 4–8 KB), како би се могли препознати Quota-/Policy-грешке.
  • Јасна ознака покушаја поновног слања: Attempt, Delay, Exception-Klasse.

API-Key никада не сме да се нађе у логу. Ако логујете Request-Body, онда то радите само у дијагностицким билдовима и са маскирањем, јер промптови могу садржати личне или пословне податке.

Постављање за наследне ситуације: VCL, FMX und Layer-3 архитектура

Много Delphi апликација ради у класичној трослојној логици („Layer-3 архитектура“: UI, пословна логика, подаци/интеграција). За повезивање са ChatGPT-ом то је корисно: приказани клиент припада слоју интеграције; пословна логика одлучује шта се пита; UI приказује само ток и статус. Тако избегавате да каснија промена (други провајдер, On-Prem-Proxy, нови ендпоинти) наруши форме.

За модернизацију Delphi-а ово је добар полазни пункт: прво стабилан клиент, затим побољшања UI-а (Streaming, Откажи, историја), па тек онда „интелигентније“ функције попут структурираних одговора или позива алата.

Закључак: Солидна основа, али не свака апликација захтева стриминг

Повезивање ChatGPT API са Delphi FMX/VCL на уредан начин се исплати нарочито тамо где су UI-реактивност, оперативна поузданост и могућност отклањања грешака важни: алати за администраторе, десктоп-клијенти блиски процесу или алати за подршку у дигиталним решењима предузећа. Приказани исечак је свесно прагматичан: SSE-стриминг без посебних библиотека, поновни покушаји само за стварне мрежне грешке, JSON дефанзивно парсиран.

Ограничења примене: Ако су вам потребни строги захтеви за усаглашеност, централно управљање prompt-овима, подршка за више закупаца или детаљни audit-трагови, обично „један клијент на десктопу“ није довољан. Тада интеграција типично припада у контролисани сервер (нпр. сопствени REST-сервис), који централно спроводи политике, логовање и контролу приступа. За многе Delphi инсталације, међутим, клијент приказан овде представља поуздану полазну тачку коју је могуће постепено интегрисати у чистију укупну архитектуру.

У стручном оквиру такође важну улогу играју Openai API у Delphi и Delphi HTTP-клијент timeout/retry када интеграције, токови података и даљи развој морају да функционишу усклађено.

Разговарајте о пројекту или плану модернизације са Net-Base.

Nächster Schritt

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

Подржавамо не само у појединачним питањима, већ и када из исечака изворног кода, застарелих тема или идеја за портале треба да настане поуздан корпоративни пројекат.

  • Постојеће стање, циљано стање и технички ризици оцењују се заједно.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Подели објаву

Поделите ову објаву директно

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

Е-пошта

Инстаграм се отвара у новој картици. Линк и кратак текст се претходно копирају у међуспремник.