实验 02 — 你的第一个流式聊天¶
目标:跟随一个提示,从 Python 堆栈的客户端 HTTP 到 FastAPI 再到 Copilot SDK,直到模型,理解为什么模型发现是运行时数据而非硬编码的列表。
用时: ~20分钟
前置条件: 实验 01 完成,FastAPI 服务器正在端口 5070 上运行。
步骤 1 — 启动或确认服务器¶
从仓库根目录:
打开 http://localhost:5070 如果您想稍后观看 UI。相同的流程同时提供 API 和静态 UI。
确认 API 是健康的:
预期结果:
{"status":"healthy","service":"CopilotChat","availableModels":["claude-haiku-4.5","gpt-4.1","gpt-5","claude-sonnet-4.5","claude-opus-4.5","gemini-2.5-pro"]}
那 fallback 列表是故意很小的。真正的模型选择器会询问已登录的 Copilot 账户它能使用什么。
步骤 2 — 观察传输格式¶
发送提示并观察原始的 Server-Sent Events:
curl -sN -X POST http://localhost:5070/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"prompt":"Reply with exactly: streaming works","model":"claude-haiku-4.5"}'
捕获输出:
三件事要注意:每一帧是data:加上JSON,后面跟着一个
空行;较长的回答以多个帧的形式到来,因为片段可以在任何地方分割;并且流以data: [DONE]结束。
⚠️ 产品名的请求模型是 prompt, model, 和可选的 systemMessage。一个未识别的键,如 message,将被默默地忽略——你将收到一个通用的问候而不是验证错误。
步骤 3 — 找到服务器端¶
打开app/routers/chat.py,并定位到stream_chat。
stream_chat 返回一个 StreamingResponse,其媒体类型为 text/event-stream。在 event_stream 内,每个 SDK 块都被序列化为一个 SSE 帧:
该路由接着写 data: [DONE] 终止标记。
⚠️ 空行不是装饰。它是事件分隔符。删掉它,许多 SSE 客户端会继续缓冲,因为它们从未看到一个完整的事件。
现在看看这个 except 块。错误被写入 流中 作为data: {"error": "..."}<code>data: {"error": "..."}</code>
步骤 4 — 找到 SDK 集成¶
打开 app/services/copilot_chat.py.
CopilotChatService.chat_stream 创建了一个启用流式传输的会话:
session = await self._client.create_session(
model=model,
streaming=True,
system_message=(
{"mode": "append", "content": system_message} if system_message else None
),
)
然后它订阅事件:
def on_event(evt: SessionEvent) -> None:
# Unlike .NET, every event arrives as one SessionEvent
# carrying a `type` enum and a `data` payload, so this
# dispatches on `evt.type` rather than on subclasses.
if evt.type is SessionEventType.ASSISTANT_MESSAGE_DELTA:
queue.put_nowait(evt.data.delta_content or "")
elif evt.type is SessionEventType.ASSISTANT_MESSAGE:
logger.info(
"Assistant response complete: %d chars",
len(evt.data.content or ""),
)
elif evt.type is SessionEventType.SESSION_IDLE:
if not done.done():
done.set_result(None)
elif evt.type is SessionEventType.SESSION_ERROR:
logger.error("Session error: %s", evt.data.message)
if not done.done():
done.set_exception(RuntimeError(evt.data.message))
The queue是SDK的回调风格和FastAPI的异步响应生成器之间的桥梁。回调将块推入asyncio.Queue;路由等待队列并生成SSE帧。
步骤 5 — 理解 Python 事件¶
这是 Python 和 .NET 之间最重要的一个区别。
.NET 示例会对事件子类进行模式匹配,例如 AssistantMessageDeltaEvent 和 SessionIdleEvent. Python 给你一个 SessionEvent 数据类,其中包含如下的字段:
typedataidtimestamp
该 type字段是一个SessionEventType枚举,因此Python代码分支如下:
if evt.type is SessionEventType.ASSISTANT_MESSAGE_DELTA:
...
elif evt.type is SessionEventType.SESSION_ERROR:
...
事件也是只推送回调。session.on(handler) 返回一个取消订阅的可调用函数;没有直接遍历的异步迭代器。
该助手在
sdk_labs/_common.py
使用这种模式等待直到 SESSION_IDLE 或抛出异常 SESSION_ERROR.
第 6 步 — 询问 API 您有哪些模型¶
/api/chat/models 返回一个 JSON 列表,其对象的结构如下:
路由首先调用 CopilotChatService.list_models(),调用
await client.list_models()那返回 ModelInfo ModelInfo对象包含.id和.name字段。 .id 以及
.name 字段。
⚠️ list_models() 已知有上游 Bug:它能引发
ValueError: Missing required field 'multiplier' in ModelBilling
(github/copilot-sdk#1302)。路由广泛捕获并退回到静态目录,因此 UI 仍然有选择。
第 7 步 — 切换模型并比较¶
从您的活列表中选择一个模型 ID:
MODEL=$(curl -s http://localhost:5070/api/chat/models | jq -r '.[0].id')
echo "Using $MODEL"
curl -sN -X POST http://localhost:5070/api/chat/stream \
-H 'Content-Type: application/json' \
-d "{\"prompt\":\"In one sentence, what is customer churn?\",\"model\":\"$MODEL\"}"
使用不同的 id 重复,并比较延迟、样式和语气。
The 智能体选择器 在
sdk_labs/model_picker.py
使用了相同的概念:优先使用 claude-haiku-4.5,但会退回到你实际可以使用的具体模型。你可以使用 --model <id> 来覆盖示例。
步骤 8 — 使用系统消息塑造响应¶
The API 接受可选 systemMessage服务将其发送在 追加模式,以便将其补充到会话的内置指令中,而不是替换它们。
curl -sN -X POST http://localhost:5070/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%。如果没有这种上下文,模型无法直接访问你的种子数据。实验03用一个模型只能在需要零售事实时调用的工具替换静态上下文。
步骤 9 — 了解基本会话调用¶
The Python SDK 的对象支持 async with,因此示例在客户端和会话中都确定性地清理。
对于非流式传输的一次性提示,SDK 也提供了:
The FastAPI服务使用send(prompt),因为它会处理每个delta作为它们到达时。
第 10 步 —— 尝试 UI 路径¶
回到浏览器中的 http://localhost:5070,选择一个模型,问
"列出三个零售关键绩效指标。一行一条。",然后观察消息以块的形式渲染。如果流失败,检查 data: {"error": "..."} 帧。
完成的交换看起来是这样的——与上述通过 curl 读取的 SSE 帧相同,由 app.js 解析并渲染为 Markdown:

💡 在流中的助手气泡显示一个动画的输入指示器;一旦到达 [DONE] 标记符,它就会被渲染的 Markdown 替换。
⚠️ 静态 UI 发布 { prompt, model } 从 app/static/app.js,匹配 ChatRequest。一个较早的版本发送了 message;由于 Pydantic 忽略未知键而不是拒绝它们,浏览器将回复流式传输到一个空提示,并且没有大声失败。现在 tests/test_chat_contract.py 断言 app.js 发布的字段正是 API 读取的字段。
✅ 检查点¶
您可以现在解释:
- [x] SSE 传输格式以及空行为何重要
- [x] 为什么错误是流式传输而不是通过 HTTP 状态码返回
- [x] 如 FastAPI 的
StreamingResponse包裹了 SDK 的流 - [x] 为什么 Python 通过
evt.type分支而不是事件子类 - [x] 模型是在运行时被发现的
- [x] 如系统消息使助手在零售领域以数据为依据
💡 拓展练习¶
打开 app/services/copilot_chat.py,并在if链之前记录每种事件类型。发送一个简短的提示,并将事件序列与助手示例进行比较:
那更深的事件生命周期示例是sdk_labs/events_sample.py。
相关¶
- 上一步: Lab 01 — 环境配置
- 下一步: Lab 03 — 工具
- 演示: Copilot SDK 集成