📖 Day 13 学习页:Web UI + SSE 流式交互
Day 13:Web UI + SSE 流式交互
🎯 今日目标:在浏览器里和你的 Agent 聊天,看到打字机式的流式回答和工具调用过程—— 服务端一个 SSE 接口 + 前端一个页面,全链路打通。
🛠 项目脚手架:project_day11_15/(开发都在这个目录里) | 📋 验收清单
⏱ 今日安排(约 2 小时)
| 时间 | 内容 | 方式 |
|---|---|---|
| 30min | 理论学习:SSE 协议 + 技术选型 | 阅读 |
| 60min | 动手实操:/chat/stream 接口 + 静态页挂载 | 编码 |
| 30min | 联调:浏览器聊天 + curl 看原始流 | 联调 |
📖 第一步:理论学习
1. 为什么用 SSE(Server-Sent Events)而不是 WebSocket
| SSE | WebSocket | |
|---|---|---|
| 方向 | 服务器 → 浏览器单向(Agent 场景够用:请求一次、回答流式推) | 双向 |
| 协议 | 普通 HTTP,天然过网关/代理 | 独立协议,部分基础设施不友好 |
| 断线 | 浏览器自动重连 | 要自己实现 |
| 结论 | LLM 对话首选 | 协同编辑/实时游戏才需要 |
SSE 的全部格式约定(你已经在 Day 3 见过生成器侧、tutorial_03 第 7 步见过解析侧):
响应头 Content-Type: text/event-stream;消息是一行行 data: 内容,
每条消息后必须跟一个空行;约定 data: [DONE] 作为结束哨兵。
2. 端到端数据流(今天要把三段管道接起来)
浏览器 static/index.html FastAPI /chat/stream llm.chat_stream
──────────────────────── ──────────────────── ──────────────
fetch(POST).body.getReader() ◄── StreamingResponse(生成器) ◄── async generator
按 \n\n 切分事件 → JSON.parse 工具轮次:非流式(要解析 tool_calls) 逐 chunk yield
type=tool → 显示工具行 最终回答:流式(打字机) data: {...}
type=answer → 追加到气泡 data: [DONE] 结束
关键设计决策(main.py 的 TODO 里已写明):工具轮次用非流式调用—— 因为中途需要解析 tool_calls 并执行;只有最终回答走流式。 这是真实项目的常见做法:过程事件即时推送,答案打字机输出。
3. 已经给你的两块
llm.py的chat_stream():适配层的流式版(SSE 解析已实现,通读一遍)static/index.html:完整的聊天前端(fetch + ReadableStream 消费 SSE,事件分 type=tool/answer 渲染)——不用改
🛠 第二步:动手实操(都在 main.py)
- TODO 6.1:实现
POST /chat/stream——按注释里的参考流程写一个异步生成器, 用StreamingResponse(生成器, media_type="text/event-stream")返回。 注意 SSE 的每条 data 行结尾要有两个换行,结束发data: [DONE]。 - TODO 6.2:挂载静态目录(
StaticFiles,三行)—— 同源部署天然没有 CORS 问题;这也是不用跨域 EventSource 方案的原因。 - 联调:
curl -N -X POST http://127.0.0.1:8000/chat/stream -H "Content-Type: application/json" -d '{"session_id":"s1","message":"你好"}'(-N 禁用缓冲,能看到 data: 行一条条蹦出来)- 浏览器开
http://127.0.0.1:8000/static/index.html聊天——打字机效果 + 工具调用过程可见 - 问"帮我计算 3 + 5 * 2"——应先蹦出 🔧 工具行,再流式打出答案
✅ 第三步:验收(Day 13 部分)
- curl -N 能看到完整的 SSE 事件序列(tool → answer×N → [DONE])
- 浏览器聊天有打字机效果,工具调用过程以蓝色小条显示
- 断开网络/停服务后刷新页面有明确错误提示(不是白屏)
- 对照 交付清单 Day 13 部分打勾
⚠️ 避坑
- ❌ 忘记 media_type="text/event-stream" → 浏览器当成普通响应,一次性吐出全部内容
- ❌ data 行后没有空行 → 事件永远不被分发(SSE 以空行分帧)
- ❌ 用 EventSource 消费 POST 接口 → EventSource 只支持 GET;用 fetch + ReadableStream(前端已给)
- ❌ 在 SSE 生成器里 print 调试 → 混进流里污染协议;用日志模块
- ❌ 忘记给生成器套 try/except → LLM 报错时流半途断掉,前端白屏