跳至内容

实验 02 — 您的第一次流式传输对话

目标: 沿完整堆栈追踪单一提示 — 浏览器 → API → Copilot SDK → 模型 → 回传 — 并了解模型列表为何在运行时提取,而不是硬编码。

时间: 约 20 分钟

先决条件: 实验 01 完成,两项服务都在执行中。

步骤 1 — 观察传输格式

发送提示词并观察原始 Server-Sent Events:

curl -N -X POST http://localhost:5050/api/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"prompt":"Name three retail KPIs. One line each.","model":"claude-haiku-4.5"}'

您现在会看到许多小数据块,而不是一个完整的大响应:

data: {"content":"1. **Average"}

data: {"content":" Transaction Value** — revenue divided by"}

data: {"content":" transaction count.\n"}

...

data: [DONE]

请注意三件事:

  1. 每个数据块都以 data: 开头,后接 JSON 和一个空白行;该空白行是 SSE 用来分隔事件的标记
  2. 块会在任意位置切分,甚至可能切在单词或句子中间。客户端必须拼接内容,不能假设每次收到完整令牌
  3. 流式传输将以哨兵值结束: data: [DONE]

步骤 2 — 找到服务器端

打开 ChatController.cs 并找到 StreamChat。请依次注意:

  • Response.ContentType = "text/event-stream" 加上 no-cache 和保持连接
  • await foreach 遍历 _chatService.ChatStreamAsync(...)
  • 每个数据块之后都会调用 await Response.Body.FlushAsync(cancellationToken)
  • 结尾的 data: [DONE]

⚠️ 清空缓冲区并非可有可无。 如果没有执行此操作,ASP.NET Core 会缓冲响应,客户端将一次收到所有内容 — 流式传输表面上仍然「运行」,但逐字输入效果会完全消失。这是构建 SSE 端点时最常见的错误。

现在查看 catch 块。错误会以 data: {"error":"..."} 的形式写入响应流,而不是返回 HTTP 500。这是无法避免的:第一个数据块发出时,状态行和响应头已经传送,因此无法再更改状态码。

步骤 3 — 找到 SDK 整合位置

打开 CopilotChatService.cs.

ChatStreamAsync 会创建会话并订阅事件:

session.On<SessionEvent>(evt =>
{
    switch (evt)
    {
        case AssistantMessageDeltaEvent delta:
            outputChannel.Writer.TryWrite(delta.Data.DeltaContent ?? "");
            break;
        case SessionIdleEvent:
            done.SetResult();
            break;
        ...
    }
});

⚠️ 明确的 <SessionEvent> 很重要。 在 SDK v1.x 中,类型参数已不再从 Lambda 推断 — session.On(evt => ...) 无法编译并显示 CS0411。针对 v0.x 编写的旧示例仍使用非泛型形式。

另外请注意 Channel<string>:SDK 会话会在后台执行 Task,而完成的块会推送至枚举器读取的通道。之所以需要这层间接处理,是因为 C# 禁止 yield return 位于 try/catch 中使用 yield return,而会话处理确实需要异常处理。

步骤 4 — 向 API 查询可用模型

curl -s http://localhost:5050/api/chat/models | jq -r '.[].id'

此实时列表来自您的账户。现在尝试一个几乎可以确定不在列表中的模型:

curl -N -X POST http://localhost:5050/api/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"prompt":"hello","model":"gpt-4-turbo-preview"}'

预期结果:

data: {"error":"... Model \"gpt-4-turbo-preview\" is not available."}

这正是 ChatController.GetModels 调用 CopilotChatService.ListModelsAsync()、而不是返回固定列表的原因。此演示的旧版本曾内置六个模型 ID;随着时间推移,其中五个已失效,但选择器仍会悄悄提供这些无法使用的模型。现在,静态列表仅在无法连接 CLI 时作为后备方案。

步骤 5 — 切换模型并比较

从实时列表中选择两个标识符,并询问相同问题:

MODEL=$(curl -s http://localhost:5050/api/chat/models | jq -r '.[0].id')
echo "Using $MODEL"

curl -N -X POST http://localhost:5050/api/chat/stream \
  -H "Content-Type: application/json" \
  -d "{\"prompt\":\"In one sentence, what is customer churn?\",\"model\":\"$MODEL\"}"

使用不同的标识符重复操作,并比较延迟与语气。在浏览器中, 模型 下拉菜单也采用相同做法 — 选择项由下列组件持久保存至 localStorage: StorageService.

💡 如果浏览器中存储的模型日后从您的账户消失, Home.razor 会在加载时检测过期值并恢复为有效值,而不会在第一次发送时失败。

步骤 6 — 使用系统消息调整响应

API 接受可选的 systemMessage,并以追加模式应用,使其补充而不是替换内置提示词:

curl -N -X POST http://localhost:5050/api/chat/stream \
  -H "Content-Type: application/json" \
  -d '{
    "prompt":"Which segment has the lowest retention?",
    "model":"claude-haiku-4.5",
    "systemMessage":"You are a retail analytics assistant. Context: 4 segments — High Value (92% retention), Regular (78%), At Risk (45%), New (65%). Answer in one sentence."
  }'

预期结果 — 根据实际数据,并指出 存在风险 为 45%。

没有该上下文,模型便无法访问您的种子数据,并会如实说明。请删除 systemMessage 后重新运行并比较。Blazor 客户端始终发送零售分析系统消息,因此 UI 看起来了解该领域;请参阅 ChatService.StreamChatAsync

本实验通过系统消息提供上下文。这些内容是静态的,而且每次调用都会消耗令牌。实验 03 会改用工具,让模型只在真正需要零售数据时调用。

✅ 检查点

您现在可以说明:

  • [x] 了解 SSE 传输格式,以及为何要清空每个数据块的缓冲区
  • [x] 了解为何错误会通过流式传输发送,而不是以 HTTP 状态码返回
  • [x] 为何 On<SessionEvent> 需要明确指定类型参数
  • [x] 了解为何模型是在运行时探索
  • [x] 了解系统消息如何让助手基于零售领域