面向 Delphi 和 .NET 的 Server-Sent Events 客户端

· 组件
面向 Delphi 和 .NET 的 Server-Sent Events 客户端

Server-Sent Events 曾是 WebSockets 那位安静的表亲:一个永不结束的普通 HTTP 响应,服务器不断写入一个又一个的小型文本事件。如今它突然无处不在。LLM API 的流式回答就是以 SSE 的形式到达的,MCP 服务器通过 Streamable HTTP 使用它,仪表盘、通知和任务进度推送也在使用它,因为它可以穿过任何放行 HTTP 的代理。

sgcWebSockets 2026.10 新增了 TsgcSSEClient,这是一个面向 Delphi、C++Builder 和 .NET 的 EventSource 客户端。它在后台线程中读取流,将每个事件连同其类型、数据和 id 一并交给你,在连接断开时自行重连,并通过 Last-Event-ID 恢复,因此中间不会丢失任何内容。该组件不包含在当前可下载的版本中,它会随 2026.10 一起发布,覆盖所有版本。

TsgcSSEClient 接收类型化事件,丢失连接后通过 Last-Event-ID 恢复。YouTube 上也有。

为什么 SSE 现在如此重要

一个 SSE 流是一个由若干简短块组成的 text/event-stream 响应:一个可选的、带有类型的 event: 行,一行或多行 data:,一个可选的 id:,以及一个用于结束该事件的空行。服务器还可以发送 retry:,告诉客户端在重连前要等待多久。这个格式很简单,但一个正确的客户端必须处理可能在任意位置被截断的分块数据、多行数据、CR、LF 和 CRLF 换行符,以及一个记得自己停在哪里的重连机制。这些正是 TsgcSSEClient 为你做的事情。

快速开始

添加 sgcHTTP_SSE_Client 单元,设置 URL,处理 OnEvent,然后调用 Open。像 bearer token 这样的请求头放在 Headers 中,每行一个 Name: Value,它们会随每一次连接发送,包括重连。

procedure TForm1.FormCreate(Sender: TObject);
begin
  oSSE := TsgcSSEClient.Create(nil);
  oSSE.URL := 'https://www.example.com/events';
  oSSE.Headers.Add('Authorization: Bearer ' + FToken);
  oSSE.OnEvent := OnSSEEvent;
  oSSE.OnError := OnSSEError;
  oSSE.Open;
end;

procedure TForm1.OnSSEEvent(Sender: TObject; const aEvent: TsgcSSEEvent);
begin
  if aEvent.EventType = 'alert' then
    ShowMessage(aEvent.Data)
  else
    Memo1.Lines.Add(aEvent.EventType + ' #' + aEvent.Id + ': ' + aEvent.Data);
end;

procedure TForm1.OnSSEError(Sender: TObject; const aError: string);
begin
  Memo1.Lines.Add('error: ' + aError);
end;

每个事件都是类型化的。EventType 携带 event: 的名称,因此一个处理程序就可以路由 alert、tick 或你的服务器发明的任何类型。在 VCL 或 FireMonkey 应用中,事件默认会被派发到主线程,因此处理程序可以直接操作界面。NotifyEvents 可以改变这一点。OnOpen 和 OnClose 会告诉你流何时开始和停止,ReadyState 会报告 sseConnecting、sseOpen 或 sseClosed。

客户端对服务器的反应方式与浏览器的 EventSource 相同:

使用 Last-Event-ID 恢复

当服务器为其事件添加 id: 字段时,客户端会把最后一个保存在 LastEventId 中,并在每次重连时通过 Last-Event-ID 请求头发送它。服务器读取这个请求头,并从该事件之后继续,因此一次断开的连接不会丢失任何消息,也不会重复任何消息。在同一个会话内,这不需要任何代码。

要在应用重启后恢复,请在停止时保存 LastEventId,并在 Open 之前恢复它。该属性在 Close 之后仍保留其值,在 Open 之前设置的值会随第一个请求一起发送。

procedure TForm1.StartStream;
begin
  if FileExists('sse_last_id.txt') then
    oSSE.LastEventId := Trim(TFile.ReadAllText('sse_last_id.txt'));
  oSSE.Open;
end;

procedure TForm1.StopStream;
begin
  oSSE.Close;
  TFile.WriteAllText('sse_last_id.txt', oSSE.LastEventId);
end;

恢复功能需要服务器的配合:它必须在其事件中发送 id:,并且尊重 Last-Event-ID 请求头。

由你掌控的重连策略

重连功能默认开启,延迟为 3 秒。ReconnectOptions 可以把它变成任何你的服务器值得拥有的策略:带乘数的指数退避、延迟的上限、避免上千个客户端在同一毫秒内一起回来的随机抖动,以及尝试次数的限制。

procedure TForm1.SetupReconnect;
begin
  oSSE.ReconnectOptions.Interval := 1000;
  oSSE.ReconnectOptions.Backoff := True;
  oSSE.ReconnectOptions.BackoffMultiplier := 2.0;
  oSSE.ReconnectOptions.MaxInterval := 30000;
  oSSE.ReconnectOptions.Jitter := 0.2;
  oSSE.ReconnectOptions.MaxAttempts := 10;
  oSSE.OnReconnect := OnSSEReconnect;
end;

MaxAttempts 统计的是连续失败的次数,并且每次连接成功打开时计数器都会重新开始,因此一个运行数周、偶尔遇到网络小故障的客户端永远不会耗尽尝试次数。服务器同样有发言权:一个 retry: 字段会在本次会话的剩余时间里替代 Interval。最终的决定权在你手中,在 OnReconnect 中,它会在每次尝试之前触发,并带上计算出的延迟:

procedure TForm1.OnSSEReconnect(Sender: TObject; aAttempt: Integer;
  var aDelay: Integer; var aCancel: Boolean);
begin
  // stop after the fifth failed attempt, otherwise wait at least 2 seconds
  if aAttempt > 5 then
    aCancel := True
  else if aDelay < 2000 then
    aDelay := 2000;
end;

TLS、代理与身份验证

该流是通过 TsgcHTTP1Client 读取的,OnBeforeConnect 会在每次连接尝试之前把它交给你。HTTP 客户端能做的任何事,SSE 客户端也能做:TLS 选项、代理、基本身份验证,或更长的读取超时。

procedure TForm1.OnSSEBeforeConnect(Sender: TObject;
  const aHTTP: TsgcHTTP1Client);
begin
  aHTTP.TLSOptions.Version := tls1_2;
  aHTTP.Proxy.Enabled := True;
  aHTTP.Proxy.Host := '192.168.1.10';
  aHTTP.Proxy.Port := 8080;
  aHTTP.ReadTimeout := 60000;
end;

OnBeforeConnect 和 OnReconnect 始终在后台线程中运行,因此不要在其中操作界面。

面向 LLM 流和 MCP 的解析器

很多时候你根本不需要一个长期存在的 EventSource。一个带有 stream: true 的 LLM 聊天补全请求是一个响应为 SSE 的 POST,一个 MCP Streamable HTTP 响应同样可以是 SSE。对于这些场景,客户端内部的解析器是公开的:TsgcSSEParser。以网络实际传递的任意大小的分块,将原始字节或文本喂给它,它会为每一个完整的事件触发 OnEvent,为每一个有效的 retry: 字段触发 OnRetry。一个分块可能在一行的中间,或者一个 UTF-8 字符的中间结束,解析器会保留剩余部分直到下一次调用。

procedure TForm1.ParseStream;
begin
  FParser := TsgcSSEParser.Create;
  FParser.OnEvent := OnParserEvent;
  // chunks arrive as the network delivers them, split anywhere
  FParser.Feed('event: content_block_delta'#10'data: {"delta":{"text":"Hel');
  FParser.Feed('lo"}}'#10#10'event: message_stop'#10);
  FParser.Feed(TEncoding.UTF8.GetBytes('data: {}'#10#10));
  // start again for the next response
  FParser.Reset;
end;

procedure TForm1.OnParserEvent(Sender: TObject; const aEvent: TsgcSSEEvent);
begin
  Memo1.Lines.Add(aEvent.EventType + ' ' + aEvent.Data);
end;

这三个分块产生了两个事件:content_block_delta,带有完整的 JSON {"delta":{"text":"Hello"}},以及 message_stop。Reset 会在下一个响应之前丢弃任何读到一半的事件,LastEventId 会告诉你解析器看到的最后一个 id。

.NET 版的 C#

sgcWebSockets 的 .NET 版本拥有同名的相同组件,位于 esegece.sgcWebSockets 命名空间中。Headers 是一个 Name: Value 字符串的列表,事件是常规的 .NET 事件。

using esegece.sgcWebSockets;

string token = args.Length > 0 ? args[0] : "";

var sse = new TsgcSSEClient();
sse.URL = "https://www.example.com/events";
sse.Headers.Add("Authorization: Bearer " + token);
if (File.Exists("sse_last_id.txt"))
    sse.LastEventId = File.ReadAllText("sse_last_id.txt").Trim();
sse.ReconnectOptions.Backoff = true;
sse.ReconnectOptions.MaxInterval = 30000;
sse.OnEvent += (sender, e) => Console.WriteLine($"{e.EventType} #{e.Id}: {e.Data}");
sse.OnError += (sender, error) => Console.WriteLine("error: " + error);
sse.Open();

Console.ReadLine();
sse.Close();
File.WriteAllText("sse_last_id.txt", sse.LastEventId);

TsgcSSEParser 也同样存在,带有针对 byte[]、其切片,以及字符串的 Feed 重载。

试用演示程序

两个版本都附带一个完全离线运行的演示程序。它在同一个应用内的 127.0.0.1、端口 5580 上托管一个小型 SSE 服务器,该服务器会推送带有 id: 的编号事件,轮换 message、tick 和 alert 这几种类型,发送一个 retry: 字段,并根据接收到的 Last-Event-ID 进行恢复。断开连接,然后看客户端如何重连并从下一个 id 继续,既不遗漏也不重复。

可用性

TsgcSSEClient 和 TsgcSSEParser 将随 sgcWebSockets 2026.10 一起发布,面向 Delphi、C++Builder 和 .NET,覆盖所有版本。它们不在你今天可以下载的版本中。当 2026.10 发布后,它会出现在下载页面上,并且该组件会注册在 SGC HTTP 调色板标签页中。

完整的参考文档,包含每一个属性、事件以及恢复指南,位于 TsgcSSEClient 帮助中。关于 sgcWebSockets 在 Server-Sent Events 方面所做的一切,请参阅 SSE 产品页面。

延伸阅读

有疑问、反馈或需要迁移方面的帮助?联系我们。回复你的会是真正编写这些代码的人。