Од тема во магазинот до проектна пракса
Соодветни страници за услуги и технички информации поврзани со објавата
Зошто „ChatGPT API со Delphi FMX/VCL“ во практика не е само POST
Кој сака да ја поврзе ChatGPT API со Delphi FMX/VCL, брзо завршува со еден едноставен HTTP-POST. Но во вистински бизнис‑софтуерски средини тоа се крши на три места: (1) тајмаути и повторни обиди (retries) мораат да бидат детерминистички, бидејќи во спротивно корисниците ќе доживеат „заглавен“ UI, (2) Streaming (Server-Sent Events, кратко SSE) често е соодветен за добра UX, но во Delphi-Threading брзо станува склониште за грешки, и (3) JSON не е само „еден објект“: пораки за грешки, проблеми со квоти, празни полиња или лесно променети формати на одговор треба да се ракуваат робустно.
Следниот извадок од код покажува пристап кој работи подеднакво во FMX и VCL: сопствен, тестабилен клиент што по избор работи не-streaming или streaming, чисто маршалира UI-ажурирања (т.е. ги извршува преку синхронизација на Main-Thread) и при грешки логира со смислени пораки. Понатаму е конструиран така што се вклопува во веќе постоечки слоевити структури (на пр. „API-Client“ во интеграцискиот слој, UI останува тенок).
Архитектонска скица: одвојување на UI, одржување на клиентот тестабилен
Во Delphi-проекти со долга историја често се наоѓа „HTTP im ButtonClick“. Тоа функционира до првиот инцидент. Препорачливо е мал клиент со:
- Конфигурација: Base-URL, API-Key, модел, тајмаути.
- Транспортен слој: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-опции (според оперативната средина).
- Парсер: JSON-декодирање, објекти за грешки, екстракција на резултати.
- UI-Hooks: Callback за Token-/Text-Streaming, но без цврста зависност од VCL/FMX контролите.
На тој начин интеграцијата во индивидуален корпоративен софтвер може да се спроведе чисто: клиентот може да се повторно искористи во сервиси, десктоп‑клиенти, админ‑алатки или Test‑Harnesses.
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 Token: тука API-Key како „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 грешки: едноставен retry со 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
// Streaming свесно започнува асинхроно за да не се блокира 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 без замрзнување на UI: HTTP-стримот се чита во позадина; ажурирањата на UI се извршуваат преку
TThread.Queue(асинхроно во главната нишка). Ова е во FMX и VCL робусниот стандарден пат. - Повторен обид при мрежни грешки: При
ENetHTTPClientExceptionсе прави повторен обид со backoff. Тоа е намерно едноставно и подоцна може да се прошири за статусни кодови (429/5xx). - Робустно JSON-парсирање: Наместо слепо кастирање на полињата, се проверува чекор по чекор. Тоа ги намалува грешките „Invalid type cast“ кај специјални одговори.
Рамковни услови, стапки и варијанти
- SSE не е „нормален JSON“: При стриминг доаѓа низа од настани, не е една единствена JSON-одговор. Затоа читањето по редови и препознавањето на
[DONE]е централно. - THTTPClient и Proxies/SSL: Во администраторските мрежи TLS-Inspection и обврски за прокси се реалност. Планирајте
THTTPClient.ProxySettingsи евентуално прашања за сертификати. Дебагирање: секогаш логирајте статусен код/хедери (без API-Key). - Стратегија за Timeout:
ResponseTimeoutе критичен при стриминг: ако изберете премногу краток временски рок, клиентот ќе пресече долги одговори. Во UI-алатки подолг timeout и копчето „Откажи“ често се поразумни отколку „кратко и тврдокорно“. - Прекратување на нишка: Снипетот не прикажува Cancel-Token. За продукциски алатки вреди да се имплементира механизам за откажување (на пр. флаг +
Http.CancelAllво поновите Delphi-верзии или преку контролирано прекинување на стримот). - Развој на моделот и API: Структурата на одговорите може да варира. Држете ги парсерите дефанзивни и централизирани, не распрснати во кодот на формите.
Дебагирање во постоечките Delphi-клиенти: Што навистина треба да логнете
Во интеграциски проекти првото пуштање во работа ретко пропаѓа поради JSON, туку поради детали на околината. Во технички лог (датотека, Eventlog, централен Logger) во пракса треба да влезат:
- Request-ID (самоодреден), Timestamp, целна URL (без Secret-Querystrings).
- HTTP-статусен код, Content-Type, должина на одговорот, времетраење.
- Скратен Response-Body при грешки (на пр. макс. 4–8 KB), за да може да се детектираат Quota-/Policy-грешки.
- Експлицитна ознака на Retry-обидите: Attempt, Delay, класа на Exception.
API-Key никогаш не припаѓа во лог. Ако логувате Request-Body, правете го тоа само во дијагностички билди и со маскирање, бидејќи Prompts можат да содржат лично или деловно чувствителни податоци.
Позиционирање за Legacy-ситуации: VCL, FMX и Layer-3 архитектура
Многу Delphi-апликации работат во класична 3-слојна логика („Layer-3 Architektur“: UI, бизнис-логика, податоци/интеграција). За поврзување со ChatGPT тоа е корисно: прикажаниот клиент припаѓа во интеграцискиот слој; бизнис-логиката одлучува што ќе се праша; UI-то само прикажува поток и статус. На тој начин избегнувате дека подоцнежен премин (друг провајдер, On-Prem-Proxy, нови крајни точки) ги „растргне“ формите.
И за модернизацијата на Delphi ова е добар почеток: прво стабилен клиент, потоа подобрувања на UI (Streaming, Откажи, Историја), и дури потоа „поразбирливи“ функции како структурирани одговори или повици на алатки.
Заклучок: Солидна основа, но не секоја апликација има потреба од Streaming
Вредно е чисто да се поврзе ChatGPT API со Delphi FMX/VCL, особено таму каде што реактивноста на корисничкиот интерфејс, стабилноста на работењето и можноста за дебагирање се важни: алатки за администрирање, процесно-блиски десктоп-клиенти или алатки за поддршка во дигитални корпоративни решенија. Прикажаниот исечок е намерно прагматичен: SSE-Streaming без специјални библиотеки, Retry само за вистински мрежни грешки, JSON парсиран дефанзивно.
Граници на применливост: Ако ви се потребни строги барања за усогласеност, централно Prompt-Governance, мултитенантност или детални Audit-Trails, „еден клиент на десктоп“ обично не е доволен. Во такви случаи поврзувањето типично припаѓа во контролирана серверска средина (на пр. сопствен REST-Service), која централно имплементира политики, логирање и контрола на пристап. За многу Delphi-инсталации, сепак, прикажаниот клиент е робусна почетна точка што може чекор по чекор да се префрли во почиста вкупна архитектура.
Во стручната околина, исто така, важна улога играат Openai API во Delphi и Delphi Http Client 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.
Не поддржуваме само при поединечни прашања, туку и кога од исечоци од изворен код, legacy-теми или идеи за портали треба да прерасне во робустен корпоративен проект.
- Постоечката состојба, целната слика и техничките ризици се проценуваат заедно.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.