实验 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]
请注意三件事:
- 每个数据块都以
data:开头,后接 JSON 和一个空白行;该空白行是 SSE 用来分隔事件的标记 - 块会在任意位置切分,甚至可能切在单词或句子中间。客户端必须拼接内容,不能假设每次收到完整令牌
- 流式传输将以哨兵值结束:
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 整合位置¶
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 -N -X POST http://localhost:5050/api/chat/stream \
-H "Content-Type: application/json" \
-d '{"prompt":"hello","model":"gpt-4-turbo-preview"}'
预期结果:
这正是 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] 了解系统消息如何让助手基于零售领域