Vom Magazinthema zur Projektpraxis
Passende Leistungs- und Technikseiten zum Beitrag
„Wie binde ich die ChatGPT API mit Delphi FMX/VCL an, ohne dass mein Desktop-Client bei schlechter Leitung einfriert – und ohne dass ich mir beim ersten Rate-Limit ein Support-Ticket nach dem anderen einfange?“ Die Frage kommt selten von Teams, die noch nie eine API integriert haben. Sie kommt von Leuten, die den Betrieb kennen: Proxy-Zwang im Firmennetz, TLS-Inspection, sporadische Timeouts, Anwender mit Doppelklick-Finger und ein UI, das unter Last „Nicht antwortend“ zeigt.
Konstruierte, aber realistische Situation: Ein VCL-Tool hängt an einer individuellen Unternehmenssoftware. Es soll Textbausteine für Supportfälle generieren. Im Test läuft der einfache HTTP-POST – im Produktivnetz kommen aber (a) gelegentliche ENetHTTPClientException beim TLS-Handshake, (b) 429-Responses bei Lastspitzen und (c) die Anforderung, dass der Benutzer den Vorgang jederzeit abbrechen kann. Streaming (Server-Sent Events, SSE: ein HTTP-Response-Stream mit fortlaufenden „data: …“-Zeilen) ist UX-seitig interessant, darf aber die UI nicht blockieren.
Der folgende Refresh setzt deshalb auf drei Dinge: saubere Trennung (Client statt „HTTP im ButtonClick“), ein SSE-Reader im Worker-Thread mit UI-marshalling, und ein Cancel-/Retry-Design, das nicht nur im Idealfall funktioniert.
Warum „nur ein POST“ bei der ChatGPT API mit Delphi FMX/VCL kippt
In Desktop-Business-Software sind es typischerweise diese Kanten:
- Threading: Netzwerk-Reads im UI-Thread führen zu Freezes; UI-Updates aus dem Worker-Thread führen zu sporadischen AVs.
- Streaming-Formate: SSE ist kein „ein JSON-Dokument“, sondern eine Sequenz von Events. Zeilenparsing und „[DONE]“-Handling sind Pflicht.
- Betriebseinflüsse: Proxy, Zertifikatskette, TLS-Inspection – plus Timeouts, die im Testnetz nie auftreten.
- Retry-Strategie: „Bei Exception nochmal“ reicht nicht. 429 verlangt Backoff, 401/403 nicht.
- Defensives JSON: Sie müssen mit fehlenden Feldern und abweichenden Strukturen rechnen, sonst ist der Parser die erste Absturzquelle.
Zwei technische Eckpunkte sind dabei gut belegbar: THTTPClient ist Delphis Standard-HTTP-Stack und unterstützt Timeouts, Requests und ProxySettings (siehe System.Net.HttpClient in der Embarcadero-Dokumentation: docwiki.embarcadero.com/RADStudio/en/System.Net.HttpClient.THTTPClient). Und UI-Updates gehören in VCL/FMX in den Main Thread – TThread.Queue ist dafür der robuste, asynchrone Standardmechanismus (Dokumentation: docwiki.embarcadero.com/RADStudio/en/System.Classes.TThread.Queue).
Architektur: ein testbarer Client statt Formular-Code
Wenn Sie die Integration länger als einen Sprint betreiben wollen, lohnt eine kleine Schichtung:
- TOpenAIChatClient als Integrationskomponente (HTTP, SSE, Parsing, Fehlerobjekte).
- Use-Case-Schicht (Prompt-Zusammensetzung, Domänenregeln, Redaction).
- UI (Start/Cancel, Fortschritt, Anzeige, Copy/Save).
Source-Schnipsel: SSE-Streaming mit Cancel, Backoff-Retry und defensivem JSON
Der Code nutzt THTTPClient und System.JSON. Er zeigt bewusst einen Randfall, der in Legacy-Desktops oft fehlt: ein explizites Cancel, das den Stream sauber abbricht, plus Retry-Regeln, die zwischen Netzwerkfehler, 429 und „nicht retrybar“ unterscheiden. UI-Callbacks laufen über TThread.Queue, damit FMX/VCL stabil bleibt.
unit NetBase.OpenAI.ChatClient;
interface
uses
System.SysUtils, System.Classes, System.Net.URLClient, System.Net.HttpClient,
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;
ICancelToken = interface
['{5F9E8C73-2DF2-4D6A-9E1E-29B8BE6D5A88}']
function IsCancelled: Boolean;
procedure Cancel;
end;
TCancelToken = class(TInterfacedObject, ICancelToken)
private
FFlag: Integer;
public
function IsCancelled: Boolean;
procedure Cancel;
end;
TOpenAIChatClient = class
private
FBaseUrl: string;
FApiKey: string;
FConnectTimeoutMs: Integer;
FResponseTimeoutMs: Integer;
procedure ApplyAuthHeaders(ARequest: IHTTPRequest);
function BuildChatRequestBody(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
AStream: Boolean): TJSONObject;
function TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;
function ExtractTextFromNonStreamingResponse(const AJsonText: string): string;
function ShouldRetryHttpStatus(AStatus: Integer): Boolean;
function ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): 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;
function ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
const AOnEvent: TChatStreamEvent): ICancelToken;
end;
implementation
uses
System.StrUtils;
{ 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;
{ TCancelToken }
procedure TCancelToken.Cancel;
begin
TInterlocked.Exchange(FFlag, 1);
end;
function TCancelToken.IsCancelled: Boolean;
begin
Result := TInterlocked.CompareExchange(FFlag, 0, 0) = 1;
end;
{ TOpenAIChatClient }
constructor TOpenAIChatClient.Create(const ABaseUrl, AApiKey: string);
begin
inherited Create;
FBaseUrl := ABaseUrl.TrimRight(['/']);
FApiKey := AApiKey;
FConnectTimeoutMs := 8000;
FResponseTimeoutMs := 120000; // Streaming: eher großzügig; Abbruch über CancelToken.
end;
procedure TOpenAIChatClient.ApplyAuthHeaders(ARequest: IHTTPRequest);
begin
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));
Msgs := TJSONArray.Create;
Msg := TJSONObject.Create;
Msg.AddPair('role', 'user');
Msg.AddPair('content', AUserPrompt);
Msgs.AddElement(Msg);
Result.AddPair('messages', Msgs);
end;
function TOpenAIChatClient.ShouldRetryHttpStatus(AStatus: Integer): Boolean;
begin
// Redaktionelle Einschätzung (bewusst genau einmal):
// 429 und 5xx sind die häufigsten "vorübergehend"-Fälle; 401/403/400 sollte man nicht automatisiert retryen.
Result := (AStatus = 429) or ((AStatus >= 500) and (AStatus <= 599));
end;
function TOpenAIChatClient.ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): IHTTPResponse;
const
MaxAttempts = 4;
var
Attempt: Integer;
DelayMs: Integer;
Resp: IHTTPResponse;
begin
DelayMs := 250;
for Attempt := 1 to MaxAttempts do
begin
try
Resp := ADoRequest();
if (Resp <> nil) and ShouldRetryHttpStatus(Resp.StatusCode) then
begin
if Attempt = MaxAttempts then
Exit(Resp);
Sleep(DelayMs);
DelayMs := Min(DelayMs * 2, 4000);
Continue;
end;
Exit(Resp);
except
on E: ENetHTTPClientException do
begin
// Netzwerk/TLS/Timeout: Backoff-Retry, aber begrenzt.
if Attempt = MaxAttempts then
raise;
Sleep(DelayMs);
DelayMs := Min(DelayMs * 2, 4000);
end;
end;
end;
Result := nil;
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
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, 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;
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.ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;
var
Http: THTTPClient;
Req: IHTTPRequest;
Resp: IHTTPResponse;
Body: TJSONObject;
Payload: TStringStream;
RespText, 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, LeftStr(RespText, 8192))
else
raise EChatApiError.Create('HTTP-Fehler ' + Resp.StatusCode.ToString, Resp.StatusCode, LeftStr(RespText, 8192));
end;
Result := ExtractTextFromNonStreamingResponse(RespText);
finally
Payload.Free;
end;
finally
Body.Free;
end;
finally
Http.Free;
end;
end;
function TOpenAIChatClient.ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
const AOnEvent: TChatStreamEvent): ICancelToken;
var
Token: ICancelToken;
begin
Token := TCancelToken.Create;
Result := Token;
TTask.Run(
procedure
var
Http: THTTPClient;
Req: IHTTPRequest;
Resp: IHTTPResponse;
Body: TJSONObject;
Payload: TStringStream;
Reader: TStreamReader;
Line, Data: string;
J: TJSONValue;
Choices, Choice0, Delta: TJSONValue;
Chunk: string;
ErrMsg, RespText: 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
RespText := Resp.ContentAsString(TEncoding.UTF8);
if TryExtractErrorMessage(RespText, ErrMsg) then
ErrMsg := 'Streaming-Fehler: ' + ErrMsg
else
ErrMsg := 'Streaming-Start fehlgeschlagen (HTTP ' + Resp.StatusCode.ToString + ').';
TThread.Queue(nil,
procedure
begin
AOnEvent(ErrMsg, True);
end);
Exit;
end;
Reader := TStreamReader.Create(Resp.ContentStream, TEncoding.UTF8, True, 4096, False);
try
while (not Reader.EndOfStream) and (not Token.IsCancelled) do
begin
Line := Reader.ReadLine;
if Line = '' then
Continue;
if not Line.StartsWith('data:') then
Continue;
Data := Line.Substring(5).Trim;
if SameText(Data, '[DONE]') then
Break;
J := TJSONObject.ParseJSONValue(Data);
try
Chunk := '';
if (J is TJSONObject) 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
Chunk := TJSONObject(Delta).GetValue<string>('content', '');
end;
end;
finally
J.Free;
end;
if Chunk <> '' then
TThread.Queue(nil,
procedure
begin
AOnEvent(Chunk, False);
end);
end;
TThread.Queue(nil,
procedure
begin
// final event (auch bei Cancel): UI kann Buttonzustände zurücksetzen.
AOnEvent('', True);
end);
finally
Reader.Free;
end;
finally
Payload.Free;
end;
finally
Body.Free;
end;
finally
Http.Free;
end;
end);
end;
end.
Was dieser Schnipsel absichtlich „anders“ macht
- CancelToken statt „Thread kill“: Der Stream-Loop prüft
IsCancelled. Das ist simpel, aber stabil. (Je nach Delphi-Version kann zusätzlich ein aktives Abbrechen über HTTP-Client-Mechanismen möglich sein; das Grundprinzip bleibt: kontrolliert beenden.) - Retry mit Statuscode-Regel: Netzwerkexceptions und 429/5xx werden mit Backoff behandelt; 401/403/400 eben nicht. Das reduziert sinnlose Wiederholungen.
- Defensives Parsing: Jeder Cast wird abgesichert. Bei Streaming werden Chunks toleriert, die kein
delta.contentliefern. - Response-Kappung bei Fehlern: Bei Exceptions wird der Body gekürzt (z. B. 8 KB). Das ist im Betrieb oft der Unterschied zwischen „nicht reproduzierbar“ und „in 5 Minuten verstanden“.
Mehrwert: Welche Timeout-/Retry-Kombination passt wozu?
| Use Case | ConnectionTimeout | ResponseTimeout | Retry-Regel | Hinweis |
|---|---|---|---|---|
| UI-Tool, nicht-streaming | 5–10 s | 30–60 s | Netzfehler + 429/5xx | Fortschrittsanzeige statt „harter“ kurzer Timeouts |
| UI-Tool, SSE-Streaming | 5–10 s | 90–180 s | wie oben, aber sparsam | Abbruch über Cancel; optional Idle-Timeout seit letztem Token |
| Batch/Service | 5–10 s | je nach SLA | 429/5xx mit jitter | Hier sind strukturierte Logs und Metriken Pflicht |
Debugging und Betrieb: Was loggen, ohne sich selbst zu schaden?
Wenn die Anbindung in ein produktives Umfeld geht, sind diese Punkte meist entscheidender als „noch ein JSON-Feld“:
- Request-ID pro Aufruf (auch für UI-Aktionen). Diese ID hängt an alle Logzeilen.
- Statuscode + Latenz (Millisekunden) und ob Retry aktiv wurde (Attempt, Delay).
- Content-Type der Response (Streaming vs. JSON) – damit sehen Sie Proxy-/Gateway-Fehlkonfigurationen schneller.
- Redaction: Authorization-Header nie loggen; Prompt/Response nur gekürzt und nur, wenn Ihr Datenschutz- und Betriebsmodell das erlaubt.
Wenn Sie für Desktop-Tools bereits strukturierte Logs nutzen (NDJSON/JSON mit Kontext wie Request-ID), lässt sich die Chat-Integration sehr gut in dieselbe Linie ziehen. Inhaltlich passt hier ein interner Verweis auf Ihre bestehende Logging-Strategie, bevor Sie später „mehr Observability“ nachrüsten.
Grenzen und Risiken: wann der Desktop-Client der falsche Ort ist
Der gezeigte Ansatz ist bewusst ein Client-Schnipsel. Er lohnt sich für interne Tools, Support-Utilities und prozessnahe Desktop-Clients, wenn Sie Kontrolle über Deployment und Policies haben. Die Grenzen sind klar:
- Key-Management: Wenn der API-Key im Client steckt, müssen Sie Secrets sauber schützen (und bei Kompromittierung rotieren). Oft ist ein serverseitiger Broker besser.
- Compliance/Audit: Wenn Sie Nachvollziehbarkeit, zentrale Policies oder Mandantenfähigkeit brauchen, gehört die Anbindung typischerweise in einen eigenen REST-Service.
- Prompt-Inhalte: Prompts können sensible Unternehmensdaten enthalten. Ohne klare Regeln für Redaction, Aufbewahrung und Zugriff ist „mal eben integrieren“ ein Risiko.
- Streaming in instabilen Netzen: SSE ist robust, aber nicht magisch. Ohne Cancel und ohne sinnvolle Timeouts erzeugen Sie „hängt“-Tickets.
Fazit: Solide Integration ist Threading plus Betrieb, nicht nur JSON
Wer die ChatGPT API mit Delphi FMX/VCL in eine gewachsene Business-Software-Landschaft integrieren will, sollte früh in zwei Dinge investieren: erstens in ein entkoppeltes Client-Modul mit defensivem Parsing und klarer Fehlerklassifikation, zweitens in ein Betriebskonzept (Timeouts, Retry, Logging ohne Secrets, Cancel). Streaming per SSE ist dann keine Spielerei, sondern eine UX-Verbesserung, die unter realen Netzwerkbedingungen stabil bleibt.
Wenn Sie merken, dass Key-Management, Governance oder Auditierung wichtiger werden als die UI-Integration, ist das kein Scheitern des Ansatzes – es ist ein Signal, die Integrationskante in einen kontrollierten Server zu verlagern.
Für dieses Thema sind auch Bearer Token Delphi REST und Delphi Http Client Timeout Retry wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Bestand, Zielbild und technische Risiken werden zusammen bewertet.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.