在前五步中,我们构建了 Agent 的核心引擎(大脑、手脚、循环逻辑)和内部状态管理(事件与存储)。现在,我们要解决一个工程体验问题:如何将 LLM 的流式输出和 Agent 的状态事件实时传输给前端浏览器?
普通的 HTTP 响应需要等所有内容生成完毕才返回,这会导致漫长的等待。WebSocket 虽然支持双向实时通信,但对于单纯的"服务端向客户端推送"场景来说过于沉重。
因此,SSE (Server-Sent Events) 是实现流式打字机效果的最佳选择。
1. 什么是 SSE?
SSE 是一种基于 HTTP 的轻量级单向通信协议。它允许服务端在单个 HTTP 连接中,向客户端源源不断地推送事件。
ChatGPT 的网页端流式输出,底层正是广泛使用了类似 SSE 的技术。
SSE 的数据格式规范:
SSE 传输的必须是纯文本。每一条事件由 event(事件类型)、data(数据负载)组成,并以双换行符 \n\n 结束。
text
event: text.delta
data: {"text": "你"}
event: text.delta
data: {"text": "好"}
event: run.completed
data: {"status": "success"}
2. SSE 通信工作流
AgentLoop API_Server Browser AgentLoop API_Server Browser #mermaid-svg-L7u6rRBdyW3FUKqQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-L7u6rRBdyW3FUKqQ .error-icon{fill:#552222;}#mermaid-svg-L7u6rRBdyW3FUKqQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-L7u6rRBdyW3FUKqQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-L7u6rRBdyW3FUKqQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-L7u6rRBdyW3FUKqQ .marker.cross{stroke:#333333;}#mermaid-svg-L7u6rRBdyW3FUKqQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-L7u6rRBdyW3FUKqQ p{margin:0;}#mermaid-svg-L7u6rRBdyW3FUKqQ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L7u6rRBdyW3FUKqQ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-L7u6rRBdyW3FUKqQ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-L7u6rRBdyW3FUKqQ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-L7u6rRBdyW3FUKqQ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-L7u6rRBdyW3FUKqQ .sequenceNumber{fill:white;}#mermaid-svg-L7u6rRBdyW3FUKqQ #sequencenumber{fill:#333;}#mermaid-svg-L7u6rRBdyW3FUKqQ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-L7u6rRBdyW3FUKqQ .messageText{fill:#333;stroke:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L7u6rRBdyW3FUKqQ .labelText,#mermaid-svg-L7u6rRBdyW3FUKqQ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .loopText,#mermaid-svg-L7u6rRBdyW3FUKqQ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-L7u6rRBdyW3FUKqQ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-L7u6rRBdyW3FUKqQ .noteText,#mermaid-svg-L7u6rRBdyW3FUKqQ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-L7u6rRBdyW3FUKqQ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L7u6rRBdyW3FUKqQ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L7u6rRBdyW3FUKqQ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L7u6rRBdyW3FUKqQ .actorPopupMenu{position:absolute;}#mermaid-svg-L7u6rRBdyW3FUKqQ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-L7u6rRBdyW3FUKqQ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L7u6rRBdyW3FUKqQ .actor-man circle,#mermaid-svg-L7u6rRBdyW3FUKqQ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-L7u6rRBdyW3FUKqQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连接关闭 (Connection Closed) POST /api/run (请求开始任务) HTTP 200 OK (Content-Type: text/event-stream) Yield: RunStartedEvent event: run.started \n data: {...} \n\n Yield: TextDeltaEvent("Hello") event: text.delta \n data: {"text":"Hello"} \n\n Yield: RunCompletedEvent event: run.completed \n data: {...} \n\n
3. Python 代码实现 (SSE 编解码器)
我们需要编写一个编码器,将第4步中定义的 BaseRunEvent 对象序列化为标准的 SSE 字符串格式。
SSE 编码器实现
python
import json
from typing import Any, Generator
# 假设我们在第4步定义的事件模型
# from events import BaseRunEvent, TextDeltaEvent
def sse_encoder(event_obj: Any) -> str:
"""
将 Python 对象编码为 SSE 格式的字符串
"""
# 如果传入的是 Pydantic 模型,先转为字典
if hasattr(event_obj, "model_dump"):
event_dict = event_obj.model_dump()
else:
event_dict = dict(event_obj)
# 提取事件名称,默认为 'message'
event_name = event_dict.pop("event", "message")
# 将剩余数据序列化为 JSON 字符串
data_str = json.dumps(event_dict, ensure_ascii=False)
# 按照 SSE 规范拼接,注意最后的双换行
return f"event: {event_name}\ndata: {data_str}\n\n"
# 使用示例
# delta_event = TextDeltaEvent(run_id="123", text="世")
# sse_string = sse_encoder(delta_event)
# print(repr(sse_string))
# 输出: 'event: text.delta\ndata: {"run_id": "123", "created_at": "...", "text": "世"}\n\n'
在 Web 框架中使用 (以 FastAPI 为例)
为了让前端能够接收 SSE,我们需要在服务端返回特定的 Content-Type: text/event-stream。以 Python 流行的 FastAPI 框架为例:
python
# 需要安装 fastapi 和 sse-starlette
# pip install fastapi uvicorn sse-starlette
from fastapi import FastAPI
from sse_starlette.sse import EventSourceResponse
import asyncio
app = FastAPI()
async def mock_agent_generator(query: str):
"""模拟 Agent 的流式事件生成"""
events = [
{"event": "run.started", "run_id": "123", "query": query},
{"event": "text.delta", "text": "你"},
{"event": "text.delta", "text": "好"},
{"event": "text.delta", "text": ","},
{"event": "text.delta", "text": "世"},
{"event": "text.delta", "text": "界"},
{"event": "run.completed", "status": "success"}
]
for evt in events:
# 模拟计算延迟
await asyncio.sleep(0.3)
# sse-starlette 期望的 dict 格式包含 event 和 data
yield {
"event": evt.get("event", "message"),
"data": json.dumps({k: v for k, v in evt.items() if k != "event"}, ensure_ascii=False)
}
@app.post("/api/chat")
async def chat_endpoint(query: str):
"""
提供 SSE 接口
"""
# EventSourceResponse 会自动设置 Content-Type: text/event-stream
return EventSourceResponse(mock_agent_generator(query))
# 启动服务器: uvicorn main:app --reload
4. 客户端解码(前端简要预览)
在浏览器端,原生提供了 EventSource 对象或 fetch API 来接收 SSE。
javascript
// 前端简易解码示例
const response = await fetch('/api/chat?query=你好');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码获取 SSE 字符串 (如 event: text.delta\ndata: {...}\n\n)
const chunk = decoder.decode(value);
console.log("收到原始数据:", chunk);
// 实际项目中需要使用 split('\n\n') 和正则进一步解析出 data 的 JSON
}
总结
通过引入 SSE 编解码,我们打通了 Agent 内部事件流到外部 HTTP 接口的最后一公里,赋予了应用原生的"打字机"实时响应能力。
但如果用户觉得 Agent 啰嗦,想要提前停止生成怎么办?下一篇,我们将进入接口通信的高阶话题:创建订阅与打断接口。