Net-Base списание

15.07.2026

ChatGPT API со Delphi FMX/VCL: Робустна интеграција со Streaming, Retry и прецизно JSON-парсирање

Како да ја поврзете ChatGPT API со Delphi во FMX/VCL на робустен начин: HTTP-клиент со време-ограничувања (timeouts) и механизам за повторување (retry), SSE-стриминг без замрзнување на корисничкиот интерфејс и робустно JSON-парсирање за повици кон алати и ракување со грешки.

15.07.2026

Од тема во магазинот до проектна пракса

Соодветни страници за услуги и технички информации поврзани со објавата

Зошто „ChatGPT API со Delphi FMX/VCL“ во практика не е само POST

Кој сака да ја поврзе ChatGPT API со Delphi FMX/VCL, брзо завршува со еден едноставен HTTP-POST. Но во вистински бизнис‑софтуерски средини тоа се крши на три места: (1) тајмаути и повторни обиди (retries) мораат да бидат детерминистички, бидејќи во спротивно корисниците ќе доживеат „заглавен“ UI, (2) Streaming (Server-Sent Events, кратко SSE) често е соодветен за добра UX, но во Delphi-Threading брзо станува склониште за грешки, и (3) JSON не е само „еден објект“: пораки за грешки, проблеми со квоти, празни полиња или лесно променети формати на одговор треба да се ракуваат робустно.

Следниот извадок од код покажува пристап кој работи подеднакво во FMX и VCL: сопствен, тестабилен клиент што по избор работи не-streaming или streaming, чисто маршалира UI-ажурирања (т.е. ги извршува преку синхронизација на Main-Thread) и при грешки логира со смислени пораки. Понатаму е конструиран така што се вклопува во веќе постоечки слоевити структури (на пр. „API-Client“ во интеграцискиот слој, UI останува тенок).

Архитектонска скица: одвојување на UI, одржување на клиентот тестабилен

Во Delphi-проекти со долга историја често се наоѓа „HTTP im ButtonClick“. Тоа функционира до првиот инцидент. Препорачливо е мал клиент со:

  • Конфигурација: Base-URL, API-Key, модел, тајмаути.
  • Транспортен слој: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-опции (според оперативната средина).
  • Парсер: JSON-декодирање, објекти за грешки, екстракција на резултати.
  • UI-Hooks: Callback за Token-/Text-Streaming, но без цврста зависност од VCL/FMX контролите.

На тој начин интеграцијата во индивидуален корпоративен софтвер може да се спроведе чисто: клиентот може да се повторно искористи во сервиси, десктоп‑клиенти, админ‑алатки или 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 и маршалираме UI-ажурирања во Main-Thread.

Delphi
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 …“.
  // Во корпоративни средини дополнително внимавајте да не се запишуваат клучевите во логовите.
  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 грешки: едноставен retry со 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
              // При грешки при стриминг, содржината често сепак е 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-ченк: 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-стримот се чита во позадина; ажурирањата на UI се извршуваат преку TThread.Queue (асинхроно во главната нишка). Ова е во FMX и VCL робусниот стандарден пат.
  • Повторен обид при мрежни грешки: При ENetHTTPClientException се прави повторен обид со backoff. Тоа е намерно едноставно и подоцна може да се прошири за статусни кодови (429/5xx).
  • Робустно JSON-парсирање: Наместо слепо кастирање на полињата, се проверува чекор по чекор. Тоа ги намалува грешките „Invalid type cast“ кај специјални одговори.

Рамковни услови, стапки и варијанти

  • SSE не е „нормален JSON“: При стриминг доаѓа низа од настани, не е една единствена JSON-одговор. Затоа читањето по редови и препознавањето на [DONE] е централно.
  • THTTPClient и Proxies/SSL: Во администраторските мрежи TLS-Inspection и обврски за прокси се реалност. Планирајте THTTPClient.ProxySettings и евентуално прашања за сертификати. Дебагирање: секогаш логирајте статусен код/хедери (без API-Key).
  • Стратегија за Timeout: ResponseTimeout е критичен при стриминг: ако изберете премногу краток временски рок, клиентот ќе пресече долги одговори. Во UI-алатки подолг timeout и копчето „Откажи“ често се поразумни отколку „кратко и тврдокорно“.
  • Прекратување на нишка: Снипетот не прикажува Cancel-Token. За продукциски алатки вреди да се имплементира механизам за откажување (на пр. флаг + Http.CancelAll во поновите Delphi-верзии или преку контролирано прекинување на стримот).
  • Развој на моделот и API: Структурата на одговорите може да варира. Држете ги парсерите дефанзивни и централизирани, не распрснати во кодот на формите.

Дебагирање во постоечките Delphi-клиенти: Што навистина треба да логнете

Во интеграциски проекти првото пуштање во работа ретко пропаѓа поради JSON, туку поради детали на околината. Во технички лог (датотека, Eventlog, централен Logger) во пракса треба да влезат:

  • Request-ID (самоодреден), Timestamp, целна URL (без Secret-Querystrings).
  • HTTP-статусен код, Content-Type, должина на одговорот, времетраење.
  • Скратен Response-Body при грешки (на пр. макс. 4–8 KB), за да може да се детектираат Quota-/Policy-грешки.
  • Експлицитна ознака на Retry-обидите: Attempt, Delay, класа на Exception.

API-Key никогаш не припаѓа во лог. Ако логувате Request-Body, правете го тоа само во дијагностички билди и со маскирање, бидејќи Prompts можат да содржат лично или деловно чувствителни податоци.

Позиционирање за Legacy-ситуации: VCL, FMX и Layer-3 архитектура

Многу Delphi-апликации работат во класична 3-слојна логика („Layer-3 Architektur“: UI, бизнис-логика, податоци/интеграција). За поврзување со ChatGPT тоа е корисно: прикажаниот клиент припаѓа во интеграцискиот слој; бизнис-логиката одлучува што ќе се праша; UI-то само прикажува поток и статус. На тој начин избегнувате дека подоцнежен премин (друг провајдер, On-Prem-Proxy, нови крајни точки) ги „растргне“ формите.

И за модернизацијата на Delphi ова е добар почеток: прво стабилен клиент, потоа подобрувања на UI (Streaming, Откажи, Историја), и дури потоа „поразбирливи“ функции како структурирани одговори или повици на алатки.

Заклучок: Солидна основа, но не секоја апликација има потреба од Streaming

Вредно е чисто да се поврзе ChatGPT API со Delphi FMX/VCL, особено таму каде што реактивноста на корисничкиот интерфејс, стабилноста на работењето и можноста за дебагирање се важни: алатки за администрирање, процесно-блиски десктоп-клиенти или алатки за поддршка во дигитални корпоративни решенија. Прикажаниот исечок е намерно прагматичен: SSE-Streaming без специјални библиотеки, Retry само за вистински мрежни грешки, JSON парсиран дефанзивно.

Граници на применливост: Ако ви се потребни строги барања за усогласеност, централно Prompt-Governance, мултитенантност или детални Audit-Trails, „еден клиент на десктоп“ обично не е доволен. Во такви случаи поврзувањето типично припаѓа во контролирана серверска средина (на пр. сопствен REST-Service), која централно имплементира политики, логирање и контрола на пристап. За многу Delphi-инсталации, сепак, прикажаниот клиент е робусна почетна точка што може чекор по чекор да се префрли во почиста вкупна архитектура.

Во стручната околина, исто така, важна улога играат Openai API во Delphi и Delphi Http Client Timeout Retry кога интеграциите, тековите на податоци и натамошниот развој треба да соработуваат на чист и предвидлив начин.

Разговарајте за проект или модернизациски потфат со Net-Base.

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.

Сподели објава

Споделете го овој пост директно.

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. Für Instagram bereiten wir Link und Kurztext direkt vor.

Е-пошта

Instagram се отвора во нов таб. Линкот и краткиот текст претходно се копираат во меѓуспремникот.