📖 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

SSEWebSocket
方向服务器 → 浏览器单向(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)

  1. TODO 6.1:实现 POST /chat/stream——按注释里的参考流程写一个异步生成器, 用 StreamingResponse(生成器, media_type="text/event-stream") 返回。 注意 SSE 的每条 data 行结尾要有两个换行,结束发 data: [DONE]。
  2. TODO 6.2:挂载静态目录(StaticFiles,三行)—— 同源部署天然没有 CORS 问题;这也是不用跨域 EventSource 方案的原因。
  3. 联调:
    • 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 报错时流半途断掉,前端白屏

🔗 延伸资源