От темы в журнале к проектной практике
Соответствующие страницы услуг и технологий к статье
Почему «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 маршалятся в главный поток.
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, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.
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.