ByteNoteByteNote
WebSocket 流式的三层架构
字

字节笔记本

2026年10月24日 · 约 12 分钟读完

WebSocket 流式的三层架构

API中转
¥120

Agent SDK 概览

本文是《从 0 写 Agent》系列第 36 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「WebSocket 流式:三层架构」。

前端要看到 Agent 实时打字、实时显示工具调用、实时收到审批请求,这需要 WebSocket 流式推送。

agents-server 把"SDK 流式事件 → 前端 UI 更新"拆成三层,连接层、分发层、运行层。这一章拆这个端到端的流式架构。


为什么是三层

SDK 的流式输出(第 6 章)是 Go channel 的事件流。但前端在浏览器里,要通过 WebSocket 接收。中间要做,

  1. 连接管理,WebSocket 鉴权、并发写安全、断线处理
  2. 事件分发,不同事件类型路由到不同处理(run 事件、审批事件、取消事件)
  3. 运行桥接,把 SDK 事件翻译成前端能理解的 protocol 事件

三层各自职责清晰,这是复杂流式系统的必要拆分。


Claude Code

第一层:连接层(server/ws.go)

WebSocket 连接的建立和鉴权:

go
// server/ws.go(概念)
upgrader := websocket.Upgrader{
    CheckOrigin: checkOrigin,  // 校验 origin 防 CSRF
}

func HandleWSWithAuth(conn, token) {
    // 强制第一条消息是 auth
    conn.ReadJSON(&msg)
    if msg.Type != "auth" || !constantTimeCompare(msg.Token, token) {
        conn.Close()
        return
    }
    conn.WriteJSON({Type: "auth.ok"})  // 鉴权成功
    // 进入业务循环
}

关键,WebSocket 升级后,HTTP 头鉴权失效,要在 WS 应用层重做 token 鉴权。 第一条消息必须是 auth,constant-time 比对防时序攻击。

WSConn 的写锁

go
type WSConn struct {
    *websocket.Conn
    mu sync.Mutex  // 并发写锁
}

func (w *WSConn) WriteJSON(v any) error {
    w.mu.Lock()
    defer w.mu.Unlock()
    return w.Conn.WriteJSON(v)
}

WebSocket 连接不支持并发写,多个 goroutine 同时 WriteJSON 会损坏帧。mu 保证串行写。这是 WebSocket 编程的必备防护。


第二层:分发层(handler/ws_handler.go)

WSHandler 的 Handle(ws_handler.go:69)是个消息循环:

go
// ws_handler.go:69(骨架)
func (h *WSHandler) Handle(conn *WSConn) {
    for {
        var msg protocol.Envelope
        conn.ReadJSON(&msg)
        switch msg.Type {
        case "run.create":
            go h.handleRunCreate(msg)  // 每个 run 一个 goroutine
        case "tool.approve":
            h.handleApprove(msg)  // HITL 审批
        case "tool.reject":
            h.handleReject(msg)
        case "run.cancel":
            h.runner.CancelRun(msg.RunID)
        }
    }
}

四种 client→server 消息,创建 run、审批、拒绝、取消。每种有自己的处理。

wsSink,事件出口

go
// ws_handler.go:62
wsSink := func(env *protocol.Envelope) {
    conn.WriteJSON(env)  // 把事件推给前端
}

wsSink 是个闭包,把"往这个连接写 JSON"封装成一个 EventSink 函数,传给 bridge 层。bridge 层产生事件时调 sink,事件就到了前端。

pending map,HITL 暂存

go
// ws_handler.go:31
pending map[string]*pendingRun  // runID → 被审批中断的 run 状态

当 run 被工具审批中断,SDK 的 RunState 存进 pending map。 用户审批后,从 pending 取出,ResumeStreamed。第 37 章详讲。


第三层:运行层(bridge/runner.go)

RunStreamed(runner.go:53)是真正的流式引擎:

go
// runner.go:53(骨架)
func (r *Runner) RunStreamed(input, agentConfig, sink) {
    // 1. 构建 Agent(从 DB 配置)
    agent := r.BuildFullAgent(agentConfig)

    // 2. 构建 provider(可能多路由)
    provider := r.BuildRouterProvider(agentConfig)

    // 3. 起 SDK 流式 Run
    sr := agents.RunStreamed(ctx, agent, input, opts)

    // 4. 逐事件消费,翻译成 protocol 事件,推给前端
    for event, err := range sr.Events() {
        r.handleStreamEvent(event, sink)  // 翻译+推送
    }
}

事件翻译

handleStreamEvent(runner.go:361)把 SDK 事件翻译成前端 protocol 事件:

SDK 事件protocol 事件前端怎么显示
RawResponsesStreamEvent(output_text.delta)run.step(带 delta)打字效果
RunItemStreamEvent(tool_called)run.tool_call"调用工具 X"
RunItemStreamEvent(tool_output)run.tool_result显示结果
RunItemStreamEvent(handoff)run.handoff"切换到 Agent Y"
AgentUpdatedStreamEventrun.agent_start新 Agent 接管
Interruptions(需审批)run.tool_call(NeedsApproval:true)弹审批框

关键,response.output_text.delta 是打字效果的唯一来源(runner.go:416 的 extractDelta)。SDK 透传的原始事件里,只有这个类型是"又吐了一个字"。

额外亮点:自动生成标题

go
// runner.go:289
func (r *Runner) maybeGenerateTitle(session, firstMessage) {
    // 用一个小 agent(title_gen,max_turns=1)给会话起标题
    title := agents.Run(ctx, titleAgent, firstMessage, ...)
    sink(&protocol.Envelope{Type: "session.title_updated", Title: title})
}

首条消息后,自动给会话生成标题(类似 ChatGPT 的"New Chat"变成实际标题)。通过事件推回前端更新侧边栏。


三层协作的完整流程

text
前端发 run.create
  → 分发层:起 goroutine
  → 运行层:BuildFullAgent → RunStreamed
  → SDK 跑循环,产出事件流
  → 运行层:handleStreamEvent 翻译
  → wsSink → 连接层:WriteJSON(持锁)
  → 前端收到 run.step(打字)/ run.tool_call / ...

前端根据 protocol 事件的 type 渲染 UI,收到 run.step 追加文字,收到 run.tool_call 显示工具调用卡片,收到审批请求弹框。


小结

读完这一章,你应该理解 WebSocket 流式的三层:

  1. 连接层,WS 鉴权(首消息 auth)、并发写锁(mu)、CheckOrigin
  2. 分发层,消息循环 + 四种消息处理 + pending map(HITL 暂存)+ wsSink 闭包
  3. 运行层,SDK RunStreamed + 事件翻译(SDK→protocol)+ 自动标题

这是"SDK 事件流 → 浏览器 UI"的完整端到端管道。 下一章讲这条管道里最复杂的部分,HITL 审批。


下一篇

第 37 章,HITL 审批的端到端实现。当 SDK 遇到危险工具暂停,agents-server 怎么把"暂停状态"推给前端,用户审批后怎么恢复?pending map + RunState 序列化 + ResumeStreamed 的完整流程。


本文是《从 0 写 Agent》系列第 36 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: