Net-Base Журнал

15.07.2026

ChatGPT API с Delphi FMX/VCL: надёжная интеграция с потоковой передачей, повторными попытками и корректным разбором JSON

Как надёжно подключить ChatGPT API к Delphi FMX/VCL: HTTP‑клиент с таймаутами и повторными попытками, SSE‑стриминг без зависаний интерфейса, а также надёжный парсинг JSON для вызовов инструментов и обработки ошибок.

15.07.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

Почему «ChatGPT API с Delphi FMX/VCL» на практике — это не просто POST

Кто пытается интегрировать ChatGPT API с Delphi FMX/VCL, быстро приходит к простому HTTP-POST. В реальных бизнес‑приложениях это даёт сбои в трёх местах: (1) таймауты и повторные попытки должны быть детерминированы, иначе пользователи увидят «зависший» UI, (2) потоковая передача (Server-Sent Events, сокращённо SSE) часто необходима для хорошего UX, но в Delphi‑многопоточии быстро становится источником ошибок, и (3) JSON — это не просто «объект»: сообщения об ошибках, проблемы с квотами, пустые поля или слегка изменённые формы ответа нужно обрабатывать надёжно.

Следующий фрагмент исходного кода демонстрирует подход, который одинаково работает в FMX и VCL: собственный, тестируемый клиент, который по выбору работает в режимах не‑streaming или streaming, корректно маршалит обновления UI (то есть выполняет их через синхронизацию с главным потоком) и при ошибках даёт информативные логи. Кроме того, он спроектирован таким образом, чтобы вписываться в уже существующие слоистые структуры (например, «API-Client» в слое интеграции, UI остаётся тонким).

Архитектурная схема: отделить UI, сохранять клиент тестируемым

В Delphi‑проектах с длительной историей часто встречается «HTTP в обработчике ButtonClick». Это работает до первого инцидента. Рекомендуется небольшой клиент с:

  • Конфигурация: Base-URL, API-Key, модель, таймауты.
  • Транспортный слой: HTTP-Request/Response, Retry, таймауты, опции Proxy/SSL (в зависимости от окружения).
  • Парсер: JSON-декодирование, объекты ошибок, извлечение результатов.
  • UI‑хуки: callback для токен-/текст‑стриминга, но без жёсткой зависимости от VCL/FMX‑контролов.

Так интеграцию в индивидуальное корпоративное ПО можно выполнять аккуратно: клиент можно повторно использовать в сервисах, десктоп‑клиентах, админ‑инструментах или Test-Harnesses.

Фрагмент исходника: Delphi‑клиент с SSE‑стримингом, Timeout/Retry и надёжным JSON

Код использует THTTPClient (System.Net.HttpClient) и сознательно парсит минимально с помощью System.JSON. Для SSE чтение производится построчно с реакцией на «data: …». Это не «WebSocket», а HTTP‑response‑stream, который непрерывно поставляет текстовые строки. Важно: чтение выполняется в воркер‑потоке, а обновления UI маршалятся в главный поток.

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-ключ как „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/таймаут-ошибки: простой повтор с увеличением задержки (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-чанка: 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 быстро становятся затратными:

  • Потоковая передача без зависаний интерфейса: HTTP-поток читается в фоновом потоке; обновления UI выполняются через TThread.Queue (асинхронно в главный поток). Это в FMX и VCL надёжный стандартный подход.
  • Повторные попытки при сетевых ошибках: При ENetHTTPClientException выполняется повтор с backoff. Это намеренно просто и в дальнейшем может быть расширено учётом кодов состояния (429/5xx).
  • Надёжный парсинг JSON: Вместо слепого приведения типов полей выполняются пошаговые проверки. Это снижает количество ошибок «Invalid type cast» при нестандартных ответах.

Ограничения, подводные камни и варианты

  • SSE — это не «обычный JSON»: При стриминге приходит последовательность событий, а не один JSON-ответ. Поэтому построчное чтение и распознавание [DONE] имеют ключевое значение.
  • THTTPClient и прокси/SSL: В административных сетях реальны TLS-Inspection и требования использования прокси. Планируйте THTTPClient.ProxySettings и, при необходимости, вопросы с сертификатами. Для отладки: всегда логируйте статус-код/заголовки (без API-Key).
  • Стратегия тайм-аутов: ResponseTimeout критичен при стриминге: если выбрать слишком малый тайм-аут, клиент оборвёт длинные ответы. В UI-инструментах чаще разумнее установить более длительный тайм-аут и предоставить кнопку «Отменить», чем применять «короткий и жёсткий» тайм-аут.
  • Отмена потока: В фрагменте кода нет Cancel-Token. Для промышленных инструментов имеет смысл реализовать механизм отмены (например, флаг + Http.CancelAll в новых версиях Delphi или контролируемое прерывание потока/stream).
  • Эволюция модели и API: Структура ответов может меняться. Держите парсер защитным и централизованным, а не распыляйте его по коду форм.

Debugging in gewachsenen Delphi-Clients: Was Sie wirklich loggen sollten

В интеграционных проектах первая установка редко терпит неудачу из‑за JSON; обычно проблема в деталях окружения. В технический лог (файл, Eventlog, центральный логгер) на практике следует записывать:

  • Request-ID (selbst vergeben), Timestamp, Ziel-URL (ohne Secret-Querystrings).
  • HTTP-Statuscode, Content-Type, Antwortlänge, Laufzeit.
  • Сокращённое тело ответа при ошибках (z. B. max. 4–8 KB), um Quota-/Policy-Fehler erkennen zu können.
  • Явная маркировка попыток повтора: Attempt, Delay, Exception-Klasse.

API-Key ни в коем случае не должен попадать в лог. Если вы логируете тело запроса, делайте это только в диагностических сборках и с маскировкой, так как промпты могут содержать персональные или коммерческие данные.

Контекст для legacy-ситуаций: VCL, FMX и Layer-3 Architektur

Многие Delphi-приложения работают по классической трёхуровневой логике («Layer-3 Architektur»: UI, бизнес-логика, данные/интеграция). Для привязки ChatGPT это удобно: показанный клиент должен находиться в слое интеграции; бизнес-логика решает, что запрашивать; UI отображает только историю и статус. Это предотвращает ситуацию, когда поздняя смена (другой провайдер, On-Prem-прокси, новые конечные точки) «разрывает» формы.

Для модернизации Delphi это тоже хорошая отправная точка: сначала стабильный клиент, затем улучшения UI (стриминг, отмена, история), и только потом «более интеллектуальные» функции, такие как структурированные ответы или вызовы инструментов.

Вывод: надёжная база, но не всем приложениям нужен стриминг

Подключение ChatGPT API с Delphi FMX/VCL стоит усилий особенно там, где важны отзывчивость интерфейса, эксплуатационная надёжность и возможность отладки: инструменты администратора, процессно-близкие настольные клиенты или средства поддержки в цифровых корпоративных решениях. Приведённый фрагмент намеренно прагматичен: SSE-стриминг без специализированных библиотек, повторы только при реальных сетевых ошибках, JSON парсится с защитой от некорректных данных.

Границы применения: если вам нужны строгие требования соответствия, централизованное управление подсказками (Prompt-Governance), поддержка многотенантности или детальные аудиторские трейлы, то «один клиент на рабочем столе» обычно не подходит. В этом случае интеграция, как правило, размещается на контролируемом сервере (например, собственный REST-сервис), который централизованно реализует политики, логирование и управление доступом. Для многих установок Delphi показанный здесь клиент, тем не менее, является надёжной отправной точкой, которую можно поэтапно перевести в более аккуратную общую архитектуру.

В профильном окружении также важны Openai API в Delphi и механизмы Http Client Timeout/Retry в Delphi, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.

Обсудить проект или задачу по модернизации с Net-Base.

Nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.

  • Текущее состояние, целевое состояние и технические риски оцениваются совместно.
  • 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 открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.