מהנושא במגזין ליישום בפרויקט
דפי שירות וטכניים רלוונטיים למאמר
מדוע „ChatGPT API mit Delphi FMX/VCL“ בפועל אינה רק POST
מי שמעוניין לחבר את ChatGPT API mit Delphi FMX/VCL יגיע במהירות ל-HTTP-POST פשוט. בסביבות תוכנה עסקיות אמיתיות זה נתקל בבעיות בשלושה תחומים: (1) Timeouts ו-Retries חייבים להיות דטרמיניסטיים, אחרת המשתמשים יחוו ממשק משתמש „תקוע“, (2) Streaming (Server-Sent Events, בקיצור SSE) לעיתים קרובות נחוץ לחוויית משתמש טובה, אך ב-threading של Delphi הוא מהיר להפוך לפגיע לשגיאות, ו-(3) JSON אינו רק „אובייקט“: הודעות שגיאה, בעיות קווטות/מכסה, שדות ריקים או צורות תגובה שעברו שינוי קל חייבים להיות מטופלים בעמידות.
קטע הקוד הבא מציג גישה שפועלת גם ב-FMX וגם ב-VCL: Client עצמאי וניתן לבדיקה, שעובד בחריגה בין לא-סטרימינג ל-סטרימינג, מבצע marshaling של עדכוני UI באופן מסודר (כלומר דרך סינכרוניזציה ל-Main-Thread) וכותב לוגים משמעותיים במקרה של שגיאות. בנוסף, הוא בנוי כך שיוכל להשתלב במבני שכבות קיימים (למשל „API-Client“ בשכבת האינטגרציה, ה-UI נשאר דק).
סקיצה ארכיטקטונית: לנתק את ה-UI, להשאיר את ה-Client ניתן לבדיקה
בפרויקטים של Delphi עם היסטוריה ארוכה לעיתים קרובות מוצאים „HTTP ב-ButtonClick“. זה עובד עד התרעת התקלה הראשונה. מומלץ לבנות Client קטן שכולל:
- תצורה: Base-URL, API-Key, Modell, Timeouts.
- שכבת תחבורה: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (je nach Betrieb).
- Parser: JSON-Decoding, אובייקטי שגיאה, חילוץ תוצאות.
- UI-Hooks: Callback ל-Token-/Text-Streaming, אבל בלי תלות חזקה ב-VCL/FMX Controls.
כך ניתן לבצע את האינטגרציה בתוכנה ארגונית מותאמת באופן מסודר: ה-Client ניתן לשימוש חוזר ב-Services, Desktop-Clients, Admin-Tools או 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 ומבצעים marshaling של עדכוני 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 כ־“Authorization: Bearer …“.
// בסביבות ארגוניות יש להקפיד שמפתחות ה-API לא ייכנסו ללוגים.
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
// הסטרימינג מופעל בכוונה באופן אסינכרוני כדי שלא לחסום 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-Chunk: 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(באופן אסינכרוני לתוך ה‑Main Thread). זו הדרך הסטנדרטית והיציבה ב‑FMX וב‑VCL. - Retry לשגיאות רשת: במקרה של
ENetHTTPClientExceptionמבוצע ניסיון חוזר עם Backoff. זו גישה מכוונת לפשטות וניתנת להרחבה מאוחר יותר לכלול קודי סטטוס (429/5xx). - ניתוח JSON יציב: במקום לבצע המרות עיוורות של שדות, נבדקים הדברים שלב‑אחר‑שלב. זה מקטין שגיאות „Invalid type cast“ בתגובות מיוחדות.
תנאים מקדמיים, מכשולים וגרסאות
- SSE אינו „JSON רגיל”: בשידור מתקבלת רצף אירועים, לא תגובת JSON יחידה. לכן קריאה שורה‑שורה וזיהוי של
[DONE]הם המרכזיים. - THTTPClient und Proxies/SSL: ברשתות מנהלים קיימים בפועל TLS‑Inspection וחובת שימוש בפרוקסי. תכננו את
THTTPClient.ProxySettingsו, במידת הצורך, סוגיות תעודות. דיבוג: תמיד לרשום סטטוסקוד/Headers (ללא API‑Key). - אסטרטגיית Timeout:
ResponseTimeoutבעייתי בשידור: אם תבחרו זמן קצר מדי, הלקוח יקצץ תגובות ארוכות. בכלי UI לעתים זמן‑המתנה ארוך וכפתור „ביטול“ מועילים יותר ממדיניות „קצר וקשוח“. - ביטול Thread: הקטע לא מציג Cancel‑Token. עבור כלים פרודוקטיביים כדאי מנגנון ביטול (למשל Flag +
Http.CancelAllבגרסאות Delphi החדשות יותר או באמצעות חתירה מבוקרת של ה‑Stream). - התפתחות המודל וה‑API: מבנה ה‑Responses יכול להשתנות. שמרו על ה‑Parser באופן הגנתי ומרוכז, לא מפוזר בקוד הטפסים.
דיבוג בלקוחות Delphi מתקדמים: מה עליכם לרשום בפועל
בפרויקטי אינטגרציה ההשקה הראשונית לעיתים נדירה נכשלת בגלל ה‑JSON, ובדרך כלל נגרמת על ידי פרטי סביבה. ללוג טכני (קובץ, Eventlog, לוגר מרכזי) צריכים להיכנס בפועל:
- Request‑ID (מוקצת על ידיכם), חותמת זמן, URL יעד (ללא מחרוזות שאילתא המכילות סודות).
- קוד סטטוס HTTP, Content‑Type, אורך התשובה, זמן ריצה.
- גוף תגובה מקוצר במקרה של שגיאות (למשל מקסימום 4–8 KB), כדי להבחין בשגיאות הקשורות למכסה/מדיניות.
- סימון מפורש של ניסיונות Retry: Attempt, Delay, מחלקת Exception.
מפתח ה‑API לא שייך ללוג לעולם. אם אתם רושמים את גוף ה‑Request, עשו זאת רק בבילדי אבחון ובעם טשטוש (masking), משום שה‑Prompts עשויים להכיל נתונים אישיים או עסקיים.
התאמה למצבי Legacy: VCL, FMX ו‑Layer-3 ארכיטקטורה
הרבה יישומים של Delphi פועלים בלוגיקה קלאסית של שלוש שכבות („Layer-3 ארכיטקטורה“: UI, לוגיקה עסקית, נתונים/אינטגרציה). לחיבור ל‑ChatGPT זה שימושי: הקליינט המוצג שייך לשכבת האינטגרציה; הלוגיקה העסקית מחליטה, מה נשאל; ה‑UI מציג רק היסטוריה ומצב. כך תמנעו מצב שבו שינוי מאוחר (ספק אחר, On‑Prem‑Proxy, נקודות קצה חדשות) יפרק את Forms.
גם עבור מודרניזציה של Delphi זהו נקודת כניסה טובה: קודם לקוח יציב, אחר כך שיפורים ב‑UI (שידור, ביטול, היסטוריה), ורק לאחר מכן תכונות „חכמות“ יותר כמו תשובות מובנות או קריאות לכלים.
מסקנה: בסיס יציב, אבל לא כל יישום צריך שידור
השילוב הנקי של ChatGPT API עם Delphi FMX/VCL משתלם במיוחד במקרים שבהם תגובתיות ה‑UI, אמינות תפעולית ויכולת ניפוי שגיאות חשובות: כלים אדמיניסטרטיביים, לקוחות שולחניים הקרובים לתהליכים או כלי תמיכה בפתרונות ארגוניים דיגיטליים. הקטע המוצג מכוון לפרגמטיות: SSE‑Streaming ללא ספריות מיוחדות, Retry רק לשגיאות רשת אמיתיות, ו‑JSON מפורש בגישה הגנתית.
גבולות יישום: אם אתם נדרשים לעמידה קפדנית בהנחיות ציות, ממשל מרכזי על prompts (Prompt-Governance), תמיכה בריבוי לקוחות (Mandantenfähigkeit) או רשומות ביקורת מפורטות, בדרך כלל „לקוח אחד על הדסקטופ“ לא יספיק. במקרים כאלה החיבור שייך בדרך כלל לשרת מבוקר (למשל שירות REST עצמאי), שמממש במרכזיות מדיניות, רישום (logging) ושליטה בגישה. עבור התקנות רבות של Delphi הלקוח המוצג כאן מהווה נקודת התחלה מהימנה, שניתן להמיר בהדרגה לארכיטקטורה כוללת נקייה יותר.
בסביבה המקצועית גם 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.