Od teme v reviji do projektne prakse
Ustrezne strani storitev in tehnični opisi k prispevku
Zakaj „ChatGPT API z Delphi FMX/VCL“ v praksi ni le POST
Kdor želi povezati ChatGPT API z Delphi FMX/VCL, hitro pristane pri preprostem HTTP-POSTu. V resničnih poslovnih programskih okoljih pa se to izkaže za problematično na treh mestih: (1) Timeouti in retriji morajo biti deterministični, saj uporabniki sicer občutijo „zamrznjen“ vmesnik, (2) Streaming (Server-Sent Events, kratko SSE) je za dobro UX pogosto smiselno, vendar je pri Delphi-threadingu hitro nagnjen k napakam, in (3) JSON ni zgolj „en objekt“: napake, težave s kvotami, prazna polja ali rahlo spremenjene oblike odgovorov morajo biti robustno obravnavane.
Naslednji izsek iz kode prikazuje pristop, ki deluje enako v FMX in VCL: lasten, testabilen odjemalec, ki deluje izbirno kot brez streaminga ali streaming, urejeno prenese UI-posodobitve (tj. izvede jih preko sinhronizacije glavne niti) in ob napakah zapisuje informativne loge. Poleg tega je zasnovan tako, da se vgradi v obstoječe slojne strukture (npr. „API-Client“ v integracijski sloj, UI ostane tanek).
Skica arhitekture: UI ločiti, ohraniti odjemalca testabilnega
V Delphi-projektih z dolgo zgodovino pogosto najdete „HTTP v ButtonClick“. To deluje do prvega incidenta. Priporočljiv je majhen odjemalec z:
- Konfiguracija: Base-URL, API-Key, model, Timeouts.
- Transportna plast: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (odvisno od okolja).
- Parser: JSON-Decoding, objekt napak, ekstrakcija rezultatov.
- UI-Hooks: Callback za token-/tekstovno streaming, vendar brez stroge odvisnosti od VCL/FMX kontrol.
Tako se integracija v individualno poslovno programsko opremo lahko izvede čisto: odjemalec je mogoče ponovno uporabiti v servisih, namiznih odjemalcih, administrativnih orodjih ali test-harnessih.
Izsek kode: Delphi-odjemalec z SSE-pretakanjem, Timeout/Retry in robustnim JSON-om
Koda uporablja THTTPClient (System.Net.HttpClient) in zavestno samo minimalno parsira z System.JSON. Za SSE se bere po vrsticah in reagira na „data: …“. To ni „WebSocket“, temveč HTTP-Response-Stream, ki neprekinjeno dostavlja tekstovne vrstice. Pomembno: beremo v worker-niti in prenašamo UI-posodobitve v glavno nit.
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: hier API-Key als „Authorization: Bearer …“.
// In Unternehmensumgebungen zusätzlich darauf achten, dass Keys nicht ins Log geraten.
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));
// Minimaler Messages-Aufbau (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
// Häufige Form: { "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('Unerwartete JSON-Antwort (kein Objekt).', 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>('choices');
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create('Unerwartete JSON-Antwort: choices fehlt/leer.', 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('Unerwartete JSON-Antwort: message fehlt.', 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
// Netzwerk-/TLS-/Timeout-Fehler: einfacher Retry mit 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-Fehler ' + 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 bewusst asynchron starten, damit FMX/VCL nicht blockiert.
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
// Bei Streaming-Fehlern ist Content oft trotzdem JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent('Streaming-Start fehlgeschlagen (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-Format: Zeilen wie "data: {...}" oder "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-Chunk auswerten: 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.
Za kaj je pristop dober
Koda rešuje tri tipične razredne težave, ki se v VCL/FMX hitro podražijo:
- Streaming brez zamrznitev UI: HTTP-stream se bere v ozadju; posodobitve UI potekajo preko
TThread.Queue(asinkrono v glavnem niti). To je v FMX in VCL robustna standardna pot. - Retry za mrežne napake: Pri
ENetHTTPClientExceptionse z backoff mehanizmom poizkusi znova. Namenoma je preprosto in se kasneje lahko razširi z obravnavo statusnih kod (429/5xx). - Robustno JSON-parsanje: Namesto slepega pretvarjanja polj se preverja korak za korakom. To zmanjša napake »Invalid type cast« pri posebnih odgovorih.
Omejitve, pastmi in variante
- SSE ni »navaden JSON«: Pri streamingu pride zaporedje dogodkov, ne en sam JSON-odgovor. Zato je branje po vrsticah in prepoznavanje
[DONE]ključno. - THTTPClient in proxyji/SSL: V administrativnih omrežjih so TLS-inspekcija in obvezni proxyji realnost. Načrtujte
THTTPClient.ProxySettingsin po potrebi vprašanja s certifikati. Za razhroščevanje: statusno kodo/glave vedno beležite (brez API-ključa). - Strategija timeoutov:
ResponseTimeoutje pri streamingu občutljiv: če je prekratek, klient odreza dolge odgovore. V UI-orodjih je daljši timeout in gumb »Prekliči« pogosto bolj smiseln kot »kratek in trd«. - Prekinitev niti: Prikazani primer ne vsebuje cancel-tokena. Za produkcijske orodje se izplača mehanizem prekinitve (npr. flag +
Http.CancelAllv novejših Delphi-verzijah ali z nadzorovanim prekinjanjem streama). - Razvoj modela in API: Struktura odgovorov se lahko razlikuje. Držite parser defenzivno in centralno, ne v kodi obrazcev razpršeno.
Razhroščevanje v zraslih Delphi-klientih: Kaj bi res morali beležiti
V integracijskih projektih prva vzpostavitev redko pade na JSON, temveč na podrobnosti okolja. V tehnični log (datoteka, Eventlog, centralni logger) je v praksi smiselno zapisovati:
- Request-ID (dodeljen samostojno), časovni žig, ciljna URL (brez secret-querystringov).
- HTTP-statusna koda, Content-Type, dolžina odgovora, čas izvajanja.
- Skrajšan response-body pri napakah (npr. maks. 4–8 KB), da je mogoče prepoznati quota-/policy-napake.
- Eksplicitna označba poskusov ponovnega poizkusa: Attempt, Delay, razred izjeme.
API-key nikoli ne sodi v log. Če beležite request-body, to delajte le v diagnostičnih buildih in z maskiranjem, saj lahko prompti vsebujejo osebne ali poslovne podatke.
Položaj v legacy-situacijah: VCL, FMX in Layer-3 arhitektura
Veliko Delphi-aplikacij teče v klasični 3-slojni logiki („Layer-3 arhitektura„: UI, poslovna logika, podatki/integracija). Za ChatGPT-povezavo je to koristno: prikazani klient spada v integracijski sloj; poslovna logika odloča, kaj se vpraša; UI prikazuje le potek in status. Tako se izognete, da kasnejša menjava (drug ponudnik, on-prem proxy, novi endpointi) »raztrga« forme.
Tudi za modernizacijo Delphi je to dober izhodiščni korak: najprej stabilen klient, nato izboljšave UI (streaming, preklic, zgodovina), šele nato »inteligentnejše« funkcije kot strukturirani odgovori ali klici orodij.
Zaključek: Trdna osnova, vendar ne vsaka aplikacija potrebuje streaming
Vredno je jasno povezati ChatGPT API z Delphi FMX/VCL, zlasti tam, kjer sta odzivnost uporabniškega vmesnika, zanesljivost delovanja in razhroščevanje pomembni: orodja za skrbnike, procesno blizu namizni odjemalci ali orodja za podporo v digitalnih poslovnih rešitvah. Prikazan izsek je namerno pragmatičen: SSE-Streaming brez specialnih knjižnic, ponovni poskusi le za resnične omrežne napake, JSON varno razčlenjen.
Omejitve uporabe: Če potrebujete stroge zahteve skladnosti, centralno Prompt-Governance, večnajemniško podporo ali podrobne revizijske sledi, ‚odjemalec na namizju‘ običajno ne zadošča. V tem primeru spada povezava tipično v kontroliran strežnik (npr. lasten REST-Service), ki centralno izvaja politike, beleženje in nadzor dostopa. Za mnoge Delphi-namestitve je prikazani odjemalec vendar zanesljiva izhodiščna točka, ki jo je mogoče postopoma prenesti v čistejšo celovito arhitekturo.
V strokovnem okolju imajo tudi Openai API v Delphi in Delphi Http Client Timeout Retry pomembno vlogo, ko morajo integracije, podatkovni tokovi in nadaljnji razvoj urejeno sodelovati.
naslednji korak
Ko iz teme nastane resničen projekt, je treba arhitekturo, obstoječe sisteme in obratovanje zgodaj obravnavati skupaj.
Ne podpiramo le pri posameznih vprašanjih, ampak tudi takrat, ko iz izrezkov izvorne kode, legacy-tem ali idej za portale nastane zanesljiv podjetniški projekt.
- Obstoječe stanje, ciljno stanje in tehnična tveganja se ocenjujejo skupaj.
- REST, dostop do podatkov, portali in Rollout ne bodo prestavljeni v kasnejše faze.
- Že zgodaj vidite, katera pot je ekonomsko in operativno vzdržna.