Net-Base マガジン

15.07.2026

ChatGPT APIをDelphi FMX/VCLで堅牢に接続:ストリーミング、再試行、確実なJSON解析

ChatGPT API を Delphi を用いた FMX/VCL アプリに堅牢に組み込む方法:タイムアウトとリトライを備えた HTTP クライアント、UI のフリーズを起こさない SSE ストリーミング、ツール呼び出しやエラーケースに耐える堅牢な JSON パース。

15.07.2026

雑誌のテーマからプロジェクト実践へ

該当記事に関連するサービス・技術ページ

なぜ「ChatGPT API mit Delphi FMX/VCL」は実務で単なるPOSTにとどまらないのか

ChatGPT API を ChatGPT API mit Delphi FMX/VCL と組み合わせて接続しようとすると、単純な HTTP-POST に落ち着きがちです。しかし実際の業務用ソフトウェア環境では、次の三点で問題が顕在化します: (1) タイムアウトとリトライは決定論的である必要があり、さもないとユーザーは「ハングした」UIを経験する、(2) ストリーミング(Server-Sent Events、略称 SSE)は良好なUXのために有用なことが多いが、Delphi のスレッドモデルでは脆弱になりやすい、そして (3) JSON は単なる「オブジェクト」ではない: エラーメッセージ、クォータ問題、空フィールド、あるいはわずかに変化した応答形式などを堅牢に扱う必要がある、という点です。

以下のソーススニペットは、FMX と VCL の両方で動作するアプローチを示します: テスト可能な専用クライアントで、状況に応じて非ストリーミングまたはストリーミングの動作を切り替え、UI 更新を適切にマーシャリング(つまりメインスレッドで同期して実行)し、エラー発生時に意味のあるログを残します。加えて、既存のレイヤー構造(例: 統合層の「API-Client」、UI は薄く保つ)に組み込みやすい設計になっています。

アーキテクチャ概要: UI を切り離し、クライアントをテスト可能に保つ

長い歴史を持つ Delphi プロジェクトでは、しばしば「ButtonClick 内での HTTP 呼び出し」を見かけます。それはインシデントが起きるまでは動作しますが、推奨されるのは小さなクライアントを用意することです。要件例:

  • 構成: Base-URL、APIキー、モデル、タイムアウト。
  • トランスポート層: HTTP リクエスト/レスポンス、リトライ、タイムアウト、プロキシ/SSL オプション(運用に応じて)。
  • パーサー: JSON デコード、エラーオブジェクト、結果抽出。
  • UI フック: トークン/テキストのストリーミング用コールバック、ただし VCL/FMX コントロールへの強い依存は避ける。

こうすることで、クライアントはサービス、デスクトップクライアント、管理ツール、テストハーネスなどに再利用して組み込めます。

Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON

このコードは THTTPClient (System.Net.HttpClient) を使用し、System.JSON で意図的に最小限のパースを行います。SSE の処理では行単位で読み取り、「data: …」 に反応します。これは「WebSocket」ではなく、継続的にテキスト行を送る HTTP レスポンスストリームです。重要な点: ワーカースレッドで読み取りを行い、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 トークン: ここでは 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/タイムアウトエラー: バックオフ付きの単純リトライを行う。
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
// ストリーミング中のエラーでは、Content が 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 auswerten: 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環境でコストが急増しがちな、3つの典型的な問題クラスを解決します:

  • UIのフリーズを起こさないストリーミング: HTTPストリームはバックグラウンドで読み取り、UIの更新はTThread.Queue経由で(メインスレッドへ非同期で)行います。これはFMXとVCLにおける堅牢な標準的手法です。
  • ネットワークエラーに対するリトライ: ENetHTTPClientException発生時にバックオフ付きで再試行します。意図的にシンプルな実装にしてあり、後でステータスコード(429/5xx)に基づく拡張が可能です。
  • 堅牢なJSONパース: フィールドを盲目的にキャストするのではなく、段階的に検証します。これにより特殊な応答で発生する「Invalid type cast」エラーを減らします。

前提条件、落とし穴、およびバリエーション

  • SSEは「通常のJSON」ではない: ストリーミングでは単一のJSON応答ではなくイベントの連続が送られます。したがって行単位の読み取りと[DONE]の検出が重要です。
  • THTTPClientとプロキシ/SSL: 管理ネットワークではTLSインスペクションやプロキシ経由の通信が現実的に発生します。THTTPClient.ProxySettingsや必要に応じた証明書関連の対処を計画してください。デバッグ時はステータスコードやヘッダを常にログに残す(APIキーは除く)こと。
  • タイムアウト戦略: ストリーミングではResponseTimeoutの設定が難しいです。短すぎるとクライアントが長い応答を切断してしまいます。UIツールでは短く厳しい設定よりも、長めのタイムアウトと「キャンセル」ボタンを用意する方が適していることが多いです。
  • スレッド中断: サンプルにはキャンセルトークンが示されていません。実運用のツールでは中断メカニズム(例えばフラグ+Http.CancelAll(新しいDelphi版で利用可能)や制御されたストリーム中断)が有用です。
  • モデルおよびAPIの進化: レスポンスの構造は変わる可能性があります。パーサーは防御的かつ中央集約的に実装し、フォームコードに分散させないでください。

成長したDelphiクライアントでのデバッグ: 実際にログすべきもの

インテグレーションプロジェクトにおいて、初回の稼働はJSONそのものではなく環境の細部でつまずくことが多いです。実務では技術的なログ(ファイル、イベントログ、集中ロガー)に以下を記録します:

  • Request-ID(自分で割り当てたもの)、タイムスタンプ、対象URL(シークレットなクエリ文字列を除く)。
  • HTTPステータスコード、Content-Type、応答長、実行時間。
  • エラー時の切り詰めたレスポンスボディ(例:最大4〜8KB)—クォータ/ポリシー関連のエラーを把握するため。
  • リトライ試行の明示的記録:Attempt、Delay、Exceptionクラス。

APIキーは決してログに含めてはいけません。リクエストボディをログに残す場合は、診断ビルドでのみ、かつマスキングを施すこと。プロンプトには個人情報や業務上の機密が含まれる可能性があります。

レガシー環境の位置づけ: VCL、FMX、およびLayer-3アーキテクチャ

多くのDelphiアプリケーションは古典的な3層ロジック(「Layer-3 Architektur」:UI、ビジネスロジック、データ/インテグレーション)で動作しています。ChatGPT接続においては、この分離が有用です:示したクライアントはインテグレーション層に置き、業務ロジックが何を問い合わせるかを決定し、UIは履歴と状態のみを表示します。これにより、後でプロバイダの変更、オンプレミスプロキシ、新しいエンドポイントなどが発生してもフォームを壊すことを避けられます。

Delphiのモダナイゼーションでも良い出発点になります:まず安定したクライアントを整備し、その後にUI改善(ストリーミング、キャンセル、履歴)、さらに構造化応答やツール呼び出しといった「より賢い」機能を導入してください。

結論:堅実な基盤だが、すべてのアプリケーションがストリーミングを必要とするわけではない

ChatGPT APIをDelphi FMX/VCLで確実に接続することは、UIの応答性、稼働信頼性、デバッグのしやすさが重要な場面、すなわち管理ツール、プロセスに近いデスクトップクライアント、企業向けデジタルソリューションのサポートツールで特に有益です。示したスニペットは意図的に実務的です: 専用ライブラリを用いないSSEストリーミング、実際のネットワーク障害時のみの再試行、JSONは堅牢にパースしています。

利用上の制約: 厳格なコンプライアンス要件、中央でのプロンプトガバナンス、マルチテナント対応、詳細な監査トレイルが必要な場合、„デスクトップ上のクライアント“だけでは通常不十分です。その場合、接続は典型的には制御されたサーバ(例: 専用の REST-Service)側に置き、ポリシー、ロギング、アクセス制御を中央で実装するべきです。ただし、多くの Delphi インストールにとって、ここで示したクライアントは段階的により整備された全体アーキテクチャへ移行可能な堅実な出発点となります。

実務的な文脈では、Openai APIのDelphiへの組み込みや、DelphiにおけるHTTPクライアントのタイムアウト・リトライも、統合、データフロー、継続的な開発が整合して機能するために重要な役割を果たします。

プロジェクトまたは近代化案件について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、およびE-Mailはすぐに利用可能です。Instagram用のリンクと短文はただちに準備します。

Eメール

Instagramは新しいタブで開きます。リンクと短文は事前にクリップボードにコピーされます。