Net-Base Списание

15.07.2026

ChatGPT API с Delphi FMX/VCL: Надеждна интеграция със стрийминг, retry и прецизен JSON-разбор

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

15.07.2026

От темата в списанието към проектната практика

Подходящи страници за услуги и технологии към публикацията

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

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

Следният изходен фрагмент показва подход, който работи еднакво в FMX и VCL: собствен, тестируем клиент, който по избор работи не-поточно или поточно, правилно маршалира актуализациите на UI (т.е. извършва ги чрез синхронизация с главния нишка) и записва информативни логове при грешки. При това е структуриран така, че да се вписва в изградени слоеви структури (например „API-Client“ в интеграционния слой, UI остава тънък).

Архитектурна скица: разкачване на UI, поддържане на клиента тестируем

В Delphi-проекти с дълга история често се среща „HTTP im ButtonClick“. Това работи до първия инцидент. Препоръчително е малък клиент с:

  • Конфигурация: Base-URL, API-Key, Modell, Timeouts.
  • Транспортен слой: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (je nach Betrieb).
  • Парсър: 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, който непрекъснато доставя текстови редове. Важно: четем в работен нишка и маршалираме актуализациите на 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 Token: тук 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 бързо стават скъпи:

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

Условия, капани и варианти

  • SSE не е „нормален JSON“: При стрийминг идва поредица от събития, а не един единствен JSON-отговор. Затова четенето ред по ред и разпознаването на [DONE] са централни.
  • THTTPClient и проксита/SSL: В административни мрежи TLS-Inspection и изисквания за прокси са реалност. Планирайте THTTPClient.ProxySettings и евентуално въпроси със сертификати. За дебъг: винаги логвайте статус код/хедъри (без API-Key).
  • Стратегия за таймаут: ResponseTimeout е при стрийминг деликатен параметър: ако изберете твърде кратък, клиентът ще отреже дълги отговори. В UI-инструменти по-дълъг таймаут и бутон „Отказ“ често са по-подходящи от „кратко и твърдо“.
  • Прекъсване на нишка: Фрагментът не показва Cancel-Token. За продуктивни инструменти си струва да имате механизъм за прекратяване (напр. флаг + Http.CancelAll в по-нови Delphi версии или чрез контролиран прекъснат стрийм).
  • Развитие на моделите и на API-то: Структурата на отговорите може да се различава. Дръжте парсърите дефанзивни и централни, а не разпръснати в кода на формите.

Дебъгване в съществуващи Delphi клиенти: Какво наистина трябва да логвате

В интеграционни проекти първоначалното пускане рядко се проваля заради JSON, а по-скоро заради детайли на средата. В техническо лого (файл, Eventlog, централен логер) на практика трябва да се записва:

  • Request-ID (присвоена от вас), времеви печат, целеви URL (без secret query-стрингове).
  • HTTP-статус код, Content-Type, дължина на отговора, време за изпълнение.
  • Съкратен Response-Body при грешки (напр. макс. 4–8 KB), за да могат да се разпознаят quota-/policy-грешки.
  • Ясна маркировка на опитите за повтор: брой опит, забавяне, клас на изключението.

API-Key никога не трябва да влиза в логовете. Ако логвате Request-Body, правете го само в диагностични билдове и с маскиране, тъй като prompts могат да съдържат лични или бизнес-данни.

Поставяне в контекста на наследствени ситуации: VCL, FMX и Layer-3 архитектура

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

Дори за модернизация на Delphi това е добра отправна точка: първо един стабилен клиент, после подобрения в UI (стрийминг, прекъсване, хронология), и едва след това „по-интелигентни“ функции като структурирани отговори или извиквания на инструменти.

Извод: Солидна основа, но не всяко приложение се нуждае от стрийминг

Интегрирането на ChatGPT API с Delphi във FMX/VCL си заслужава особено там, където реактивността на потребителския интерфейс, надеждността при експлоатация и възможностите за отстраняване на грешки са важни: административни инструменти, десктоп клиенти близо до процеса или инструменти за поддръжка в цифрови корпоративни решения. Показаният фрагмент е съзнателно прагматичен: SSE-стрийминг без специални библиотеки, повторни опити само при реални мрежови грешки, JSON се парсва защитно.

Граници на приложимостта: Ако имате строги изисквания за съответствие, централизирана Prompt-Governance, поддръжка на множество клиенти или детайлни записи за одит, „един клиент на десктопа“ обикновено не е достатъчен. Тогава интеграцията типично трябва да бъде в контролиран сървър (например собствен REST-сървис), който централизирано прилага политики, логиране и контрол на достъпа. За много Delphi инсталации показаният тук клиент обаче е надеждна отправна точка, която може стъпка по стъпка да се преведе в по-чиста цялостна архитектура.

В професионалния контекст важна роля играят и Openai API в Delphi и Delphi — таймаути и повторни опити на HTTP клиента, когато интеграциите, потоците от данни и по-нататъшното развитие трябва да работят заедно безпроблемно.

Обсъдете проект или модернизационна инициатива с 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.

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

Instagram се отваря в нов раздел. Връзката и краткият текст се копират предварително в клипборда.