在 Delphi 中流式输出 LLM 响应:用 Server-Sent Events 逐 token 输出

· 组件

快速回答:要在 Delphi 中用 sgcWebSockets 流式输出 LLM 响应,请赋值组件的 OnHTTPAPISSE 事件,然后调用流式方法 — 对 OpenAI 用带 Stream := True_CreateChatCompletion,对 Anthropic Claude 和 Ollama 用 _CreateMessageStream,对 Gemini 用 _CreateContentStream。每个增量都会作为一个 Server-Sent Event 到达处理函数;把 aData 追加到 Memo,答案就会随着模型的生成自己逐字打出来。

非流式的 LLM 调用会阻塞到整个答案就绪为止。对于一段话的回复这没问题,但对于一个很长的补全,用户会盯着一个冻结的窗口好几秒,毫无反馈。流式输出解决了这个问题:模型把它的输出作为一系列小块通过单个 HTTP 连接返回,你在每一块到达的那一刻就把它渲染出来。结果就是你在 ChatGPT 中看到的那种熟悉的“打字”效果,以及一个即使完整答案需要一会儿也感觉响应灵敏的界面。

流式输出如何工作:OnHTTPAPISSE

在底层,每个 sgcWebSockets AI 组件都使用 Server-Sent Events(SSE)进行流式输出。提供商保持响应处于打开状态,并在产生 token 时推送事件。组件解析这些事件,并通过单一处理函数为每个块触发一个事件,每个组件上的签名都相同:

procedure TForm1.HandleSSE(Sender: TObject;
  const aEvent, aData: string; var Cancel: Boolean);
begin
  // aEvent  -> the SSE event name (provider-specific)
  // aData   -> the payload for this chunk (the token delta)
  // Cancel  -> set True to abort the stream early
  Memo1.Lines.Add(aData);
end;

这就是全部约定。aData 携带增量负载,aEvent 告诉你这是哪种事件(对于发出多种事件类型的提供商很有用),而设置 Cancel := True 会停止流,例如当用户点击“停止”按钮时。同样的处理函数形状适用于 OpenAI、Anthropic、Gemini 和 Ollama,所以一旦你写好一次,就可以在各个提供商之间复用。

OpenAI:Stream := True

对于 TsgcHTTP_API_OpenAI,你在请求上选择启用流式输出,并挂接事件。设置 OpenAIOptions.ApiKey,赋值 OnHTTPAPISSE,聊天补全就会逐块交付,而不是一整块:

uses
  sgcHTTP_API_OpenAI;

var
  OpenAI: TsgcHTTP_API_OpenAI;
begin
  OpenAI := TsgcHTTP_API_OpenAI.Create(nil);
  OpenAI.OpenAIOptions.ApiKey := 'sk-...';

  // Each token delta arrives in HandleSSE
  OpenAI.OnHTTPAPISSE := HandleSSE;

  // Build a typed request, set Stream := True, then call
  OpenAI._CreateChatCompletion('gpt-4o-mini', 'Explain WebSockets in detail.');
end;

类型化的请求暴露了一个 Stream 属性,所以当你自己构建请求对象时,在发送前设置 Stream := True。token 随后会在生成时通过 OnHTTPAPISSE 浮现出来,你把每一个追加到你的 Memo 中。

Anthropic Claude:_CreateMessageStream

Claude 暴露了一个专用的流式辅助方法,所以没有单独的标志需要切换:在 TsgcHTTP_API_Anthropic 上调用 _CreateMessageStream 就会为该请求开启 SSE。设置 API 密钥和版本,赋值处理函数,然后调用它:

uses
  sgcHTTP_API_Anthropic;

var
  Anthropic: TsgcHTTP_API_Anthropic;
begin
  Anthropic := TsgcHTTP_API_Anthropic.Create(nil);
  Anthropic.AnthropicOptions.ApiKey := 'sk-ant-...';
  Anthropic.AnthropicOptions.AnthropicVersion := '2023-06-01';

  Anthropic.OnHTTPAPISSE := HandleSSE;
  Anthropic._CreateMessageStream(
    'claude-3-5-sonnet-latest',
    'Summarise RFC 6455',
    1024);
end;

Claude 在流式输出时会发出多种 SSE 事件类型(内容块开始、增量,然后停止)。如果你需要,aEvent 参数让你能区分它们;对于一个简单的“随到随显示文本”的界面,追加 aData 就足够了。

Gemini 和 Ollama:同样的形状

Google Gemini 遵循完全相同的模式,使用它自己的流式方法,即 TsgcHTTP_API_Gemini 上的 _CreateContentStream

Gemini.OnHTTPAPISSE := HandleSSE;
Gemini._CreateContentStream(
  'gemini-2.0-flash',
  'Explain quantum entanglement',
  1024);

本地模型的运行方式完全一样。TsgcHTTP_API_Ollama 不需要 API 密钥 — 把 OllamaOptions.BaseUrl 指向 http://localhost:11434/api 并调用 _CreateMessageStream,运行在你自己硬件上的开源模型就会通过同样的 OnHTTPAPISSE 处理函数流式返回:

Ollama.OllamaOptions.BaseUrl := 'http://localhost:11434/api';
Ollama.OnHTTPAPISSE := HandleSSE;
Ollama._CreateMessageStream('llama3', 'Summarise RFC 6455');

四个提供商,一个事件,各自一次方法调用。切换流式后端是一次局部修改,而不是重写。

安全地更新界面

给处理函数的几条实用提示。首先,把 OnHTTPAPISSE 内部的工作量保持得很小 — 追加增量然后返回。每个 token 的繁重处理会让流感觉卡顿,所以应累积文本,并在流结束后做一次开销大的格式化。其次,注意线程上下文。如果你从后台线程发起请求,SSE 事件会在那个线程上触发,而在主线程之外触碰 VCL 或 FMX 控件是不安全的。这种情况下,用 TThread.Synchronize(或用 TThread.Queue 做非阻塞追加)把更新编排回主线程:

procedure TForm1.HandleSSE(Sender: TObject;
  const aEvent, aData: string; var Cancel: Boolean);
begin
  TThread.Queue(nil,
    procedure
    begin
      Memo1.SelStart := Length(Memo1.Text);
      Memo1.SelText := aData; // append at the caret, no full repaint
    end);
end;

SelText 而不是 Lines.Add 来追加,可以避免在每个 token 上重排整个 Memo,这能让长流保持流畅。如果你从主线程调用 API,可以去掉 TThread.Queue 包装,直接更新控件。

开始使用

所有这些组件都随 sgcWebSockets 一起提供。获取免费试用版,拖入你想要的提供商对应的组件,赋值 OnHTTPAPISSE 并调用流式方法 — 你只需几行代码就能拥有一个逐 token 的界面。完整的方法参考见 OpenAI 组件页面Anthropic 组件页面,或在 AI & LLM 组件中心浏览每一个模型。

有疑问或反馈?联系我们 — 你会收到来自代码编写者本人的回复。

相关内容