Από το θέμα του περιοδικού στην πρακτική εφαρμογή του έργου
Σχετικές σελίδες υπηρεσιών και τεχνολογίας για το άρθρο
Γιατί „ChatGPT API με Delphi FMX/VCL“ στην πράξη δεν είναι απλά ένα POST
Όποιος θέλει να ενσωματώσει τη ChatGPT API με Delphi FMX/VCL, καταλήγει γρήγορα σε ένα απλό HTTP-POST. Σε πραγματικά περιβάλλοντα επιχειρηματικού λογισμικού όμως αυτό αποτυγχάνει σε τρία σημεία: (1) Timeouts και retries πρέπει να είναι ντετερμινιστικά, γιατί αλλιώς οι χρήστες βιώνουν „κολλημένο“ UI, (2) Streaming (Server-Sent Events, συντομογραφία SSE) είναι συχνά απαραίτητο για καλή UX, αλλά στο Delphi-threading γίνεται γρήγορα επιρρεπές σε σφάλματα, και (3) JSON δεν είναι απλά „ένα αντικείμενο“: μηνύματα σφάλματος, ζητήματα με τα όρια χρήσης, κενά πεδία ή ελαφρώς μεταβαλλόμενες μορφές απαντήσεων πρέπει να χειρίζονται με ανθεκτικότητα.
Το ακόλουθο απόσπασμα πηγαίου κώδικα δείχνει μια προσέγγιση που λειτουργεί εξίσου σε FMX και VCL: Ένας ανεξάρτητος, δοκιμάσιμος client, που εργάζεται είτε μη-streaming είτε streaming, marshalt με τάξη τις ενημερώσεις του UI (δηλαδή εκτελεί τον απαραίτητο συγχρονισμό στο κύριο νήμα) και καταγράφει τα σφάλματα με περιεκτικό τρόπο. Παράλληλα είναι δομημένος ώστε να εντάσσεται σε υπάρχουσες layer-δομές (π.χ. «API-Client» στην στρώση ολοκλήρωσης, το UI παραμένει λεπτό).
Architektur-Skizze: UI entkoppeln, Client testbar halten
Σε Delphi-έργα με μακρά ιστορία συχνά συναντά κανείς «HTTP im ButtonClick». Αυτό λειτουργεί μέχρι το πρώτο περιστατικό. Συνιστάται ένας μικρός client με:
- Διαμόρφωση: Base-URL, API-Key, μοντέλο, Timeouts.
- Στρώμα μεταφοράς: HTTP-Request/Response, Retry, Timeout, επιλογές Proxy/SSL (ανάλογα με το περιβάλλον).
- Αναλυτής: JSON-decoding, αντικείμενα σφάλματος, εξαγωγή αποτελέσματος.
- UI-Hooks: Callback για token-/text-streaming, αλλά χωρίς σκληρή εξάρτηση από VCL/FMX στοιχεία ελέγχου.
Με αυτόν τον τρόπο η ενσωμάτωση σε εξατομικευμένο εταιρικό λογισμικό μπορεί να γίνει με τάξη: Ο client επαναχρησιμοποιείται σε services, desktop-clients, admin-tools ή test-harnesses.
Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON
Ο κώδικας χρησιμοποιεί THTTPClient (System.Net.HttpClient) και κάνει επίτηδες μόνο ελάχιστο parsing με System.JSON. Για SSE γίνεται ανά γραμμή ανάγνωση και αντιδρά σε „data: …“. Αυτό δεν είναι «WebSocket», αλλά ένα HTTP-Response-Stream που παρέχει συνεχείς γραμμές κειμένου. Σημαντικό: διαβάζουμε σε ένα worker-thread και κάνουμε marshal τις ενημερώσεις του 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-Key ως ‚Authorization: Bearer …‘.
// Σε εταιρικά περιβάλλοντα, να διασφαλίζετε ότι τα κλειδιά δεν καταλήγουν στα logs.
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: απλό επανπροσπάθεια με 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
// Σε σφάλματα κατά το streaming, το περιεχόμενο συχνά παραμένει JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Εκκίνηση streaming απέτυχε (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-Stream διαβάζεται στο παρασκήνιο· οι ενημερώσεις του UI γίνονται μέσω
TThread.Queue(ασύγχρονα στο κύριο νήμα εκτέλεσης). Αυτό είναι ο ανθεκτικός, προτεινόμενος τρόπος σε FMX και VCL. - Retry για σφάλματα δικτύου: Σε περίπτωση
ENetHTTPClientExceptionγίνεται επανειλημμένη προσπάθεια με backoff. Αυτό είναι σκόπιμα απλό και μπορεί να επεκταθεί αργότερα για κωδικούς κατάστασης (429/5xx). - Ανθεκτικό JSON-Parsing: Αντί να κάνετε τυφλά cast πεδίων, γίνεται σταδιακός έλεγχος. Αυτό μειώνει τα σφάλματα «Invalid type cast» σε ειδικές απαντήσεις.
Περιορισμοί, παγίδες και παραλλαγές
- SSE δεν είναι „κανονικό JSON“: Στο streaming προκύπτει μια ακολουθία γεγονότων, όχι μία ενιαία απάντηση JSON. Γι‘ αυτό το ανά γραμμή διάβασμα και η αναγνώριση του
[DONE]είναι κεντρικά. - THTTPClient και Proxies/SSL: Σε δίκτυα διαχείρισης η TLS-Inspection και οι υποχρεώσεις proxy είναι πραγματικότητα. Προβλέψτε
THTTPClient.ProxySettingsκαι ενδεχομένως ζητήματα πιστοποιητικών. Αποσφαλμάτωση: καταγράψτε πάντα τον κωδικό κατάστασης και τα headers (χωρίς API-Key). - Στρατηγική Timeout:
ResponseTimeoutείναι ευαίσθητο στο streaming: αν ορίσετε πολύ μικρό χρόνο, ο client μπορεί να αποκόψει μεγάλες απαντήσεις. Σε εργαλεία με UI συχνά είναι προτιμητέος ένας μεγαλύτερος timeout και ένα κουμπί «Ακύρωση» παρά μια «σύντομη και αυστηρή» ρύθμιση. - Διακοπή νήματος: Το παράδειγμα δεν δείχνει Cancel-Token. Για παραγωγικά εργαλεία αξίζει να υπάρχει μηχανισμός ακύρωσης (π.χ. flag +
Http.CancelAllσε νεότερες Delphi-εκδόσεις ή μέσω ελεγχόμενης διακοπής του stream). - Εξέλιξη μοντέλου και API: Η δομή των Responses μπορεί να διαφέρει. Κρατήστε τους parser αμυντικούς και κεντρικούς, όχι διασπαρμένους στον κώδικα των φορμών.
Αποσφαλμάτωση σε υφιστάμενους Delphi-Clients: Τι πρέπει πραγματικά να καταγράφετε
Στα έργα ενσωμάτωσης η πρώτη θέση σε λειτουργία σπάνια αποτυγχάνει λόγω του JSON, αλλά λόγω λεπτομερειών του περιβάλλοντος. Σε ένα τεχνικό log (αρχείο, Eventlog, κεντρικός Logger) στην πράξη πρέπει να περιλαμβάνονται:
- Request-ID (αυτή που αναθέτετε), χρονική σήμανση, διεύθυνση URL προορισμού (χωρίς ευαίσθητα query-strings).
- Κωδικός κατάστασης HTTP, Content-Type, μήκος απάντησης, χρόνος εκτέλεσης.
- Συντομευμένο σώμα απάντησης (response body) σε περίπτωση σφάλματος (π.χ. max. 4–8 KB), για να μπορείτε να εντοπίζετε σφάλματα quota/policy.
- Ειδική επισήμανση των προσπαθειών επανάληψης: απόπειρα, καθυστέρηση, κλάση εξαίρεσης.
Το API-Key δεν πρέπει ποτέ να μπει στο log. Αν καταγράφετε το Request-Body, τότε μόνο σε Diagnose-Builds και με μάσκες, επειδή τα prompts μπορούν να περιέχουν προσωπικά δεδομένα ή επιχειρηματικές πληροφορίες.
Κατάταξη για legacy περιπτώσεις: VCL, FMX και Layer-3 αρχιτεκτονική
Πολλές εφαρμογές Delphi τρέχουν σε μια κλασική λογική 3-επιπέδων («Layer-3 Architektur»: UI, επιχειρησιακή λογική, δεδομένα/ενσωμάτωση). Για τη σύνδεση με ChatGPT αυτό είναι χρήσιμο: Ο παρουσιαζόμενος Client ανήκει στο επίπεδο ενσωμάτωσης· η επιχειρησιακή λογική αποφασίζει τι ζητείται· η UI δείχνει μόνο το ιστορικό και την κατάσταση. Με αυτόν τον τρόπο αποφεύγετε μια μελλοντική αλλαγή (άλλος πάροχος, On-Prem-Proxy, νέα endpoints) να «σπάσει» τις φόρμες.
Ακόμη και για τον εκσυγχρονισμό Delphi αυτό είναι ένα καλό σημείο εκκίνησης: πρώτα ένας σταθερός Client, μετά βελτιώσεις στην UI (Streaming, Ακύρωση, Ιστορικό), και μετά «πιο έξυπνα» χαρακτηριστικά όπως δομημένες απαντήσεις ή κλήσεις εργαλείων.
Συμπέρασμα: Στερεή βάση, aber nicht jede Anwendung braucht Streaming
Η σύνδεση της ChatGPT API με Delphi FMX/VCL με τρόπο καθαρό αξίζει ιδιαίτερα εκεί όπου η απόκριση της διεπαφής χρήστη, η αξιοπιστία κατά τη λειτουργία και η ευκολία εντοπισμού σφαλμάτων είναι κρίσιμες: εργαλεία διαχείρισης, desktop clients κοντά στις διεργασίες ή εργαλεία υποστήριξης σε ψηφιακές επιχειρησιακές λύσεις. Το παρουσιαζόμενο απόσπασμα είναι σκόπιμα πραγματιστικό: SSE-Streaming χωρίς ειδικές βιβλιοθήκες, Retry μόνο για πραγματικά σφάλματα δικτύου, JSON αναλυμένο με αμυντικό τρόπο.
Όρια εφαρμογής: Εάν χρειάζεστε αυστηρές απαιτήσεις συμμόρφωσης, κεντρική διακυβέρνηση των prompts, υποστήριξη πολλαπλών ενοικιαστών ή λεπτομερή αρχεία ελέγχου, ένας «πελάτης στο Desktop» συνήθως δεν αρκεί. Σε αυτή την περίπτωση η σύνδεση τυπικά πρέπει να τοποθετηθεί σε έναν ελεγχόμενο διακομιστή (π.χ. δικός REST-Service), που εφαρμόζει κεντρικά πολιτικές, καταγραφή και έλεγχο πρόσβασης. Για πολλές Delphi-εγκαταστάσεις, ο εδώ παρουσιαζόμενος client όμως αποτελεί ένα αξιόπιστο σημείο εκκίνησης που μπορεί σταδιακά να μεταφερθεί σε μια καθαρότερη συνολική αρχιτεκτονική.
Στο τεχνικό πλαίσιο παίζουν επίσης σημαντικό ρόλο η Openai API στο Delphi και τα Delphi Http Client Timeout Retry, όταν ενσωματώσεις, ροές δεδομένων και περαιτέρω ανάπτυξη πρέπει να συνεργαστούν απρόσκοπτα.
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.