實作課程 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,而且工作階段工作確實需要例外狀況處理。
步驟 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() ,而不是傳回固定清單。此示範的舊版本曾內建六個模型識別碼;隨時間經過,其中五個已失效,但選擇器仍悄悄提供使用時會失敗的模型。現在,只有在無法連上 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] 瞭解系統訊息如何讓助理以零售領域為依據