Agent开发第6步:实现 SSE 编解码

第1步:抽象模型接口

第2步:定义工具抽象

第3步:实现 Agent Loop

第4步:定义 RunEvent

第5步:实现 Run Store

第6步:实现 SSE 编解码

第7步:创建订阅与打断接口

第8步:开发简易客户端

第9步:持久化与 Checkpoint

第10步:异常处理与测试

在前五步中,我们构建了 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 啰嗦,想要提前停止生成怎么办?下一篇,我们将进入接口通信的高阶话题:创建订阅与打断接口

相关推荐
熊猫钓鱼>_>1 小时前
【SenseNova U1.5 Lite实战】鸿蒙校园工具开发者适配原生统一多模态大模型全记录
人工智能·华为·ai·harmonyos·媒体·sensenova
龙亘川1 小时前
智慧景区建设实践|文旅热度持续走高,景区如何把流量稳稳转化为口碑?
大数据·人工智能·科技·信息可视化·智慧城市
天远API2 小时前
零信任架构实战:基于天远行驶OCR证识别构建自动化智能停车网关
运维·人工智能·架构·自动化
实名上网宋凯宣2 小时前
【Codex-智能体】
人工智能·codex·skills
CTA终结者2 小时前
量化实现难,先看交易想法有没有说清
人工智能·python
若丶相见2 小时前
Codex、Claude Code、WorkBuddy + Tabbit CLI:让 AI 操控浏览器发文章
人工智能·浏览器
拼搏奋斗,无悔于青春2 小时前
AI演示看着很厉害,一上线怎么就不行了?试驾的路,是销售挑好的
人工智能
昇腾知识体系2 小时前
昇腾 AscendC Tiling 设计实战:TilingFunc/TilingData 完整示例与 UB 容量预算
人工智能·华为·知识图谱
hqyjzsb2 小时前
Python 技术人转型 AI:CAIE Level I、Level II 对比怎么选
开发语言·人工智能·python·金融·数据挖掘·数据分析·aigc