Lehden aiheesta projektikäytäntöön
Artikkeliin liittyvät palvelu- ja tekniikkasivut
Miksi „ChatGPT API mit Delphi FMX/VCL“ käytännössä ei ole pelkkä POST
Jos haluaa liittää ChatGPT API:n ja Delphi FMX/VCL:ään, päädyt helposti yksinkertaiseen HTTP-POSTiin. Aidossa business-ohjelmistoympäristössä tämä kompastuu kuitenkin kolmessa kohdassa: (1) aikakatkaisut ja uudelleenyritykset on määriteltävä deterministisesti, muuten käyttäjät kokevat käyttöliittymän jumiutumisen, (2) streaming (Server-Sent Events, lyhyesti SSE) on usein parempaa UX:ää varten järkevä, mutta Delphi-säikeistämisessä nopeasti virhealtis, ja (3) JSON ei ole vain ”yksi objekti”: virheilmoitukset, kiintiöongelmat, tyhjät kentät tai hieman muuttuneet vastausrakenteet on käsiteltävä robustisti.
Seuraava lähdekoodikatkelma esittelee lähestymistavan, joka toimii sekä FMX:ssä että VCL:ssä: oma, testattava client, joka voi työskennellä joko ei-streaming tai streaming-tilassa, siirtää UI-päivitykset siististi pääsäikeelle (eli suorittaa ne pääsäikeen synkronoinnin kautta) ja kirjaa virheet selkeästi. Lisäksi se on rakennettu siten, että se istuu olemassa oleviin kerrosrakenteisiin (esim. „API-Client“ integraatiokerrokseen, käyttöliittymä pysyy ohueena).
Arkkitehtuurisketsi: käyttöliittymän irrottaminen, clientin pitäminen testattavana
Pitkän historian Delphi-projekteissa näkee usein „HTTP im ButtonClick“. Se toimii kunnes tulee ensimmäinen häiriötilanne. Suositeltava ratkaisu on pieni client, jossa on:
- Konfiguraatio: Base-URL, API-Key, malli, aikakatkaisut.
- Siirtokerros: HTTP-Request/Response, uudelleenyritys, aikakatkaisu, Proxy-/SSL-asetukset (riippuen käytöstä).
- Parseri: JSON-dekoodaus, virheobjektit, tulosten poiminta.
- UI-koukut: callback token-/tekstivirtausta varten, mutta ilman tiukkaa riippuvuutta VCL/FMX-kontrolleihin.
Tällä tavalla integraatio yksilölliseen yritysohjelmistoon voidaan hoitaa siististi: client on uudelleenkäytettävissä palveluissa, työpöytäasiakasohjelmissa, ylläpitotyökaluissa tai testiharneyksiköissä.
Lähdekoodikatkelma: Delphi-Client SSE-streamingillä, aikakatkaisu/uudelleenyritys ja robusti JSON
Koodi käyttää THTTPClient (System.Net.HttpClient) -luokkaa ja jäsentää tarkoituksellisesti vain minimalistisesti System.JSON:lla. SSE:ssä luetaan rivikohtaisesti ja reagoidaan „data: …“ -riveihin. Tämä ei ole „WebSocket“, vaan HTTP-vastausvirta, joka toimittaa jatkuvasti tekstirivejä. Tärkeää: luemme työssä-säikeessä ja marshalaamme UI-päivitykset pääsäikeeseen.
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.
Mihin lähestymistapa sopii
Koodi ratkaisee kolme tyypillistä ongelmaryhmää, jotka VCL/FMX:ssä voivat nopeasti käydä kalliiksi:
- Streaming ilman käyttöliittymän jäätymisiä: HTTP-streami luetaan taustalla; käyttöliittymäpäivitykset ajetaan
TThread.Queue-kutsujen kautta (asynkronisesti pääsäikeeseen). Tämä on FMX:ssä ja VCL:ssä vakiintunut, luotettava tapa. - Uudelleenyritys verkkovirheissä: Kun esiintyy
ENetHTTPClientException, yritetään uudelleen viiveellä (backoff). Tämä on tietoisesti yksinkertaista ja myöhemmin laajennettavissa tilakoodeilla (429/5xx). - Kestävä JSON-jäsennys: Sen sijaan, että kenttiä muunnettaisiin sokeasti, tarkistetaan vaiheittain. Tämä vähentää „Invalid type cast“ -virheitä erikoisvastauksissa.
Reunaehdot, sudenkuopat ja vaihtoehdot
- SSE ei ole „tavallinen JSON“: Streamingissä tulee tapahtumasarja, ei yksittäistä JSON-vastausta. Siksi rivikohtainen lukeminen ja
[DONE]:n tunnistus ovat keskeisiä. - THTTPClient ja proxyt/SSL: Admin-verkostoissa TLS-tarkastus ja pakolliset proxyt ovat todellisuutta. Suunnittele
THTTPClient.ProxySettingsja tarvittaessa sertifikaattiasiat etukäteen. Debuggaus: kirjaa aina statuskoodi ja headerit (ilman API-avainta). - Aikakatkaisustrategia:
ResponseTimeouton streaming-tilanteissa herkkä: jos asetat liian lyhyen, asiakasohjelma katkaisee pitkät vastaukset. UI-työkaluissa pidempi timeout ja „Peruuta“-painike ovat usein järkevämpiä kuin „lyhyt ja kova“. - Säikeen keskeytys: Esimerkki ei näytä Cancel-tokenia. Tuotantotyökaluihin kannattaa lisätä keskeytysmekanismi (esim. lippu +
Http.CancelAlluudemmissa Delphi-versioissa tai kontrolloitu streamin katkaisu). - Mallin ja API:n kehittyminen: Vastausten rakenne voi muuttua. Pidä parseri defensiivisenä ja keskitettynä, älä hajauta sitä lomakekoodiin.
Debuggaus olemassa olevissa Delphi-asiakkaissa: was Sie wirklich loggen sollten
Integraatioprojekteissa ensimmäinen käyttöönotto epäonnistuu harvoin JSON:in vuoksi, ja useammin ympäristöön liittyvien yksityiskohtien takia. Tekniseen lokiin (tiedosto, Eventlog, keskitetty logger) käytännössä kuuluu:
- Request-ID (itse määritetty), aikaleima, kohde-URL (ilman salaisia query-parametreja).
- HTTP-statuskoodi, Content-Type, vastauspituus, käsittelyaika.
- Lyhennetty response-body virhetilanteissa (esim. max. 4–8 KB), jotta quota-/politiikkavirheet voidaan tunnistaa.
- Selkeä merkintä uudelleenyrityksistä: yrityskerta (Attempt), viive (Delay), poikkeusluokka (Exception-Klasse).
API-avainta ei koskaan tule kirjoittaa lokiin. Jos lokitat request-bodyn, tee se vain diagnostisissa buildeissa ja maskattuna, koska promptit voivat sisältää henkilötietoja tai liiketoimintakriittistä sisältöä.
Sijoitus legacy-tilanteisiin: VCL, FMX und Layer-3-Arkkitehtuuri
Monet Delphi-sovellukset toimivat klassisessa 3-kerroslogiikassa („Layer-3 Architektur“: UI, liiketoimintalogiikka, data/integrointi). ChatGPT-liitännälle tämä on hyödyllistä: esitetty client kuuluu integraatiokerrokseen; liiketoimintalogiikka päättää, mitä kysytään; UI näyttää vain historian ja tilan. Näin vältetään, että myöhempi muutos (toinen tarjoaja, On-Prem-Proxy, uudet päätepisteet) rikkoo lomakkeet.
Myös Delphi-modernisointiin tämä on hyvä lähtökohta: ensin vakaa client, sitten UI-parannukset (Streaming, Peruuta, Historia), ja vasta sen jälkeen „älykkäämmät“ ominaisuudet kuten strukturoidut vastaukset tai työkalukutsut.
Yhteenveto: Vankka perusta, mutta kaikki sovellukset eivät tarvitse streamingia
ChatGPT-rajapinnan liittäminen Delphi FMX/VCL:llä oikein kannattaa erityisesti tilanteissa, joissa käyttöliittymän reaktiokyky, toimintavarmuus ja virheenkorjattavuus ovat tärkeitä: ylläpitotyökalut, prosessiläheiset työpöytäasiakkaat tai tukityökalut digitaalisissa yritysratkaisuissa. Näytetty koodinpätkä on tietoisesti pragmaattinen: SSE-striimaus ilman erikoiskirjastoja, uudelleenkokeilu vain todellisissa verkkovirheissä, JSON parsitaan defensiivisesti.
Käyttörajoitukset: Jos tarvitsette tiukkoja noudattamisvaatimuksia (compliance), keskitettyä prompt-hallintaa, monen asiakkaan tukea tai yksityiskohtaisia audit-lokeja, pelkkä „työpöytäasiakas“ ei yleensä riitä. Tällöin liityntä kuuluu tyypillisesti kontrolloituun palvelimeen (esim. oma REST-service), joka toteuttaa käytännöt, lokituksen ja käyttöoikeuksien hallinnan keskitetysti. Monille Delphi-asennuksille tässä esitetty client kuitenkin muodostaa luotettavan lähtökohdan, jonka voi vaiheittain siirtää osaksi selkeämpää kokonaisarkkitehtuuria.
Ammattimaisessa ympäristössä myös Openai API In Delphi ja Delphi Http Client Timeout Retry näyttelevät merkittävää roolia, kun integraatioiden, tietovirtojen ja jatkokehityksen on toimittava saumattomasti yhdessä.
Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Emme tue pelkästään yksittäiskysymyksissä, vaan myös silloin, kun lähdekoodipalasista, legacy-aiheista tai portaali-ideoista halutaan muodostaa luotettava yrityshanke.
- Nykytila, tavoitetila ja tekniset riskit arvioidaan yhdessä.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.