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

相关推荐
Hi202402174 小时前
Vortex CUDA 生态适配:让 CUDA C、CUTLASS 与 Triton 在 RISC-V GPGPU 上运行
人工智能·risc-v·gpgpu
龙腾AI白云7 小时前
AI检索增强生成(RAG):解决大模型幻觉的核心落地技术
数据库·人工智能·机器学习·知识图谱
云票7 小时前
企业对接AI合同审查系统的工程实践
人工智能
admin and root7 小时前
「AI安全篇」实战AntiDebug自动化JS逆向加解密MCP
javascript·人工智能·网络安全·自动化·漏洞挖掘·cnvd·src赏金
智能RPA7 小时前
智能体自动化平台与主数据管理平台(MDM)对比评测
人工智能·自动化·agent·rpa
跨境小彭8 小时前
Temu拉美站点铺货实操复盘:手动复制痛点与批量自动化解决方案
服务器·人工智能·搜索引擎·自动化·temu电商运营
封印师请假去地球钓鱼8 小时前
边解边变的问题:从“决策依赖“一词出发
人工智能·算法
AI搅拌机8 小时前
ComfyUI管理大师:安全稳定升级+切换指定版本!
人工智能
浅安的邂逅8 小时前
20929-OpenAI 一天踩三脚急刹:暂停前沿训练、叫停 Astra、披露越权访问澳政府网站
人工智能·大模型·ai编程·行业动态·ai日报
Qyr998 小时前
2026-2032直接芯片液冷板市场爆发式增长:AI算力浪潮下的热管理核心赛道
大数据·人工智能