Claude Code 深度拆解:远程模式 3 — 消息路由与传输层

Hi,大家好,欢迎来到维元码簿。

本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,专注于 消息路由与传输层------理解一条用户消息如何从 claude.ai 穿越 WebSocket/SSE,经过回声消除和消息去重,最终注入本地 Agent 进程;权限请求又如何反向穿越,在网页弹窗后把"允许/拒绝"传回本地。

本文聚焦一件事:Bridge 的消息中枢如何用不到 500 行代码实现一个可靠的分布式消息系统------包括回声消除、去重、控制指令协议、V1/V2 双传输适配和客户端镜像。

读完全文,你将能回答这几个问题:

  • 为什么每条发出的消息会"回声"回来?怎么消除? 答案:SSE 是全双工镜像------你发出的消息也会通过读流回来。BoundedUUIDSet 环形缓冲区用 O(2000) 的固定内存解决了这个问题。
  • 服务端发给客户端的 control_request,不回复会怎样? 服务端等待 ~10-14 秒,超时就杀 WebSocket。这是一个硬性约定------客户端必须尽快响应。
  • V1 和 V2 的传输层差异如何在代码中隔离? ReplBridgeTransport 统一接口------V1 适配 HybridTransport,V2 组合 SSETransport + CCRClient。上层代码不关心底层用的是什么。
  • RemoteSessionManager 和 Bridge 是什么关系? 镜像关系------Bridge 面向本地 Agent("把远程消息注入本地"),RemoteSessionManager 面向远程用户("把本地消息发送到远程")。

阅读提示 :如果你刚开始了解远程模式,推荐先读本系列的远程模式专栏完整版。本文聚焦消息路由与传输层,可独立阅读,不依赖其他姊妹篇上下文。


本篇覆盖的源码范围

模块 核心文件 核心代码行 职责
消息路由 src/bridge/bridgeMessaging.ts L1-462 入站消息解析、回声消除、control_request 处理
传输层抽象 src/bridge/replBridgeTransport.ts L1-371 V1/V2 适配器、统一 ReplBridgeTransport 接口
客户端会话管理 src/remote/RemoteSessionManager.ts L1-344 WebSocket 连接、权限请求响应、消息收发
写栅栏 src/bridge/flushGate.ts L1-72 transport 重建期间的消息排队状态机

前情提要:消息路由层解决的是什么问题

在 V1 篇我们拆解了环境注册和轮询------那是"连接怎么建立"。在 V2 篇我们拆解了直连和 transport 重建------那是"连接怎么维护"。在鉴权篇我们拆解了七步准入链------那是"谁能连进来"。

本文进入最后一层:消息流。 连接建立之后,消息怎么流动?这不是一个简单的"转发数据帧"问题------因为你需要同时面对回声消除(全双工镜像)、消息去重(网络重传)、控制指令协议(服务端到客户端的反向通道)、V1/V2 双传输适配(同一份上层代码)四重挑战。

答案是 Bridge 的消息中枢------不到 500 行代码,用三个设计(环形缓冲区去重、超时约定的 control_request 协议、统一传输接口)撑起了一个可靠的分布式消息系统。


第一章:入站消息路由器------为什么需要三种消息类型的分流

在深入代码之前,先理解一个设计决策:为什么 handleIngressMessage() 要先分类型再处理,而不是统一解析?

因为从 WebSocket/SSE 收到的一条数据帧可能对应三种完全不同的业务语义:① 远程用户点击了"允许/拒绝"按钮(control_response)------需要立即回调给等待中的 Agent;② 服务端发来初始化/切模型/中断指令(control_request)------需要尽快响应,超时 ~10-14 秒否则 WebSocket 被杀;③ 用户输入的对话消息(SDKMessage)------需要去重后再转发。

这三种消息的处理策略完全不同:control_response同步应答 (Agent 在等),control_request超时敏感 (不回复就断连),SDKMessage可容错去重(重复消息丢弃即可)。如果统一解析再分发,就不得不按最严格的条件处理所有消息------比如对普通对话消息也加超时检查,完全是浪费。

理解了这个设计动机,再来看 handleIngressMessage() 的三种分流:

1.1 三种消息类型

typescript 复制代码
export function handleIngressMessage(
  data: string,
  recentPostedUUIDs: BoundedUUIDSet,
  recentInboundUUIDs: BoundedUUIDSet,
  onInboundMessage: ...,
  onPermissionResponse?: ...,
  onControlRequest?: ...,
): void {
  const parsed = normalizeControlMessageKeys(jsonParse(data))

  // 类型 ①:control_response --- 远程用户对权限请求的回答
  if (isSDKControlResponse(parsed)) {
    onPermissionResponse?.(parsed)
    return
  }

  // 类型 ②:control_request --- 服务端发来的控制指令
  if (isSDKControlRequest(parsed)) {
    onControlRequest?.(parsed)
    return
  }

  // 类型 ③:SDKMessage --- 实际的对话消息(user/assistant/system)
  if (!isSDKMessage(parsed)) return

  // ...去重和转发逻辑
}

三种消息类型的处理顺序有讲究:

顺序 类型 来源 作用 处理方式
control_response claude.ai 前端 远程用户点击了"允许/拒绝" 直接回调
control_request 服务端 初始化、切模型、切权限、中断 必须快速响应
SDKMessage 服务端镜像 远程用户输入 + 本地消息回声 去重后转发

1.2 回声消除------为什么要去重

SSE/WebSocket 是全双工镜像------你通过 write path 发送的消息会通过 read path 回到客户端。如果不消除,会形成死循环

复制代码
用户发消息 → writeBatch 发送 → SSE 读回 → 当作新消息转发 → 又 writeBatch 发送 → ...

源码用两层防御解决这个问题:

第一层:recentPostedUUIDs(容量 2000 环形缓冲区)

typescript 复制代码
// bridgeMessaging.ts --- BoundedUUIDSet
export class BoundedUUIDSet {
  private readonly capacity: number
  private readonly ring: (string | undefined)[]
  private readonly set = new Set<string>()
  private writeIdx = 0

  add(uuid: string): void {
    if (this.set.has(uuid)) return
    const evicted = this.ring[this.writeIdx]
    if (evicted !== undefined) this.set.delete(evicted)
    this.ring[this.writeIdx] = uuid
    this.set.add(uuid)
    this.writeIdx = (this.writeIdx + 1) % this.capacity
  }

  has(uuid: string): boolean { return this.set.has(uuid) }
}

这不是普通的 Set------它是环形缓冲区 + Set 的组合。当容量满时,最早的消息 UUID 被淘汰。对于长时间运行的会话,2000 条消息的缓冲足够覆盖回声窗口。

第二层:initialMessageUUIDs(无界 Set 回退)

如果 recentPostedUUIDs 因为大量实时写入而淘汰了初始消息 UUID,initialMessageUUIDs 作为回退确保初始消息的回声也能被过滤。

1.3 入站去重

除了回声消除,还有一层入站去重:

typescript 复制代码
// 防御性去重:跳过已转发的入站消息
if (uuid && recentInboundUUIDs.has(uuid)) {
  // SSE 序列号协商可能失败,服务端可能重放历史
  return
}

SSE 序列号传递(initialSequenceNum)是主要的防重放机制。但序列号协商可能因 edge case 失败------服务端忽略 from_sequence_num、transport 在收到任何帧之前挂掉等。recentInboundUUIDs 是 safety net。

1.4 只转发 user 类型消息

typescript 复制代码
if (parsed.type === 'user') {
  if (uuid) recentInboundUUIDs.add(uuid)
  void onInboundMessage?.(parsed)  // fire-and-forget
} else {
  // 忽略 assistant、system 等非 user 消息
}

为什么只转发 user 类型? 因为 assistant 消息(模型回复)和 system 消息(内部事件)由本地 Agent 自己产生。远程端只需要发送用户输入------其他一切都是本地的。


第二章:Control Request 协议------为什么服务端需要一个"反向控制通道"

WebSocket/SSE 的主要方向是服务端→客户端推送消息。但如果只有这个方向,服务端无法"命令"客户端做任何事------切模型、中断执行、切换权限模式,这些都只能由本地用户操作。

control_request 就是为此而生的。它是服务端到客户端的反向控制通道------不只是被动推送,而是主动指令+等待回复。这个协议的硬性约束是 ~10-14 秒超时------不回复=杀掉连接。下面看五种指令类型:

2.1 五种指令

subtype 作用 响应内容
initialize 握手初始化 { commands: [], models: [], account: {}, pid }
set_model 切换模型 触发 onSetModel 回调
set_max_thinking_tokens 设置思考深度 触发 onSetMaxThinkingTokens
set_permission_mode 切换权限模式 需策略检查,可能返回 error
interrupt 中断当前执行 触发 onInterrupt 回调

关键约束:服务端等待 control_response 的超时是 ~10-14 秒。 如果不回复,WebSocket 会被杀死。所以客户端收到 control_request 必须尽快响应。源码作者把这个约束写进了注释:

Must respond promptly or the server kills the WS (~10-14s timeout).

2.2 initialize 的特殊性

initialize 是所有指令中唯一必须成功 的(即使在 outboundOnly 模式下)。其他可变请求(interrupt、set_model 等)在 outboundOnly 模式下返回 error,只有 initialize 必须成功------否则服务端杀掉连接。

响应内容虽然是最小化的(空 commands、空 models、空 account),但格式必须完整。pid(进程 ID)是唯一填充的有效字段。

2.3 set_permission_mode 的策略检查

typescript 复制代码
case 'set_permission_mode': {
  const verdict = onSetPermissionMode?.(request.request.mode) ?? {
    ok: false,
    error: 'set_permission_mode is not supported in this context'
  }
  if (verdict.ok) { /* success */ }
  else { /* error with verdict.error */ }
}

不是用户想切什么模式就切什么模式------onSetPermissionMode 回调会检查 isAutoModeGateEnabledisBypassPermissionsModeDisabled。如果组织策略禁用了某个模式,服务端会收到 error 而不是假成功。

2.4 outboundOnly 模式的特殊处理

typescript 复制代码
if (outboundOnly && request.request.subtype !== 'initialize') {
  response = {
    type: 'control_response',
    response: {
      subtype: 'error',
      request_id: request.request_id,
      error: 'This session is outbound-only. Enable Remote Control locally to allow inbound control.'
    }
  }
  // 返回 error 而非假成功------claude.ai 上看到明确的错误信息
}

返回 error 而不是假成功是关键。 如果返回假成功,claude.ai 上显示"模型已切换",但本地什么都没发生------用户体验非常困惑。error 明确告知"这个会话是只出不进的"。


第三章:传输层抽象------V1 和 V2 的统一接口

replBridgeTransport.ts 定义了 ReplBridgeTransport 统一接口,屏蔽了 V1 和 V2 的传输差异。

3.1 统一接口

typescript 复制代码
type ReplBridgeTransport = {
  write(msg): Promise<void>              // 单条发送
  writeBatch(msgs): Promise<void>        // 批量发送
  close(): void                          // 关闭
  isConnectedStatus(): boolean           // 连接状态
  setOnData(cb): void                    // 读回调
  setOnClose(cb): void                   // 关闭回调
  setOnConnect(cb): void                 // 连接就绪回调
  connect(): void                        // 建立连接
  getLastSequenceNum(): number           // SSE 序列号(V2 用)
  reportState(state): void               // 状态汇报(V2 用)
  reportMetadata(metadata): void         // 元数据汇报(V2 用)
  reportDelivery(eventId, status): void  // 投递确认(V2 用)
  flush(): Promise<void>                 // 排空写队列(V2 用)
}

这个接口的设计遵循了 "上层不关心传输协议" 的原则。replBridge.tsremoteBridgeCore.ts 的代码只操作 ReplBridgeTransport,不关心底层是 WebSocket 还是 SSE。

3.2 V1 适配器:createV1ReplTransport

typescript 复制代码
export function createV1ReplTransport(hybrid: HybridTransport): ReplBridgeTransport {
  return {
    write: msg => hybrid.write(msg),
    writeBatch: msgs => hybrid.writeBatch(msgs),
    close: () => hybrid.close(),
    // V1 不用 SSE 序列号------始终返回 0
    getLastSequenceNum: () => 0,
    // V1 不用的 V2 功能------no-op
    reportState: () => {},
    reportMetadata: () => {},
    reportDelivery: () => {},
    flush: () => Promise.resolve(),
    // ...
  }
}

V1 适配器本质上是 HybridTransport 的透传包装。getLastSequenceNum 始终返回 0(V1 不需要序列号传送),V2 专属的 reportStatereportMetadatareportDeliveryflush 都是 no-op。

3.3 V2 适配器:createV2ReplTransport

V2 适配器比 V1 复杂得多------它组合了两个独立组件:

typescript 复制代码
export async function createV2ReplTransport(opts): Promise<ReplBridgeTransport> {
  // ① SSE 流(读):服务端 → 客户端
  const sse = new SSETransport(sseUrl, {}, sessionId, undefined, initialSequenceNum, getAuthHeaders)

  // ② CCRClient(写):客户端 → 服务端
  const ccr = new CCRClient(sse, new URL(sessionUrl), {
    getAuthHeaders,
    heartbeatIntervalMs,
    heartbeatJitterFraction,
    onEpochMismatch: () => { /* epoch 冲突 → 关闭 + 通知 */ },
  })

  // ③ connect():sse.connect() 是 fire-and-forget,ccr.initialize(epoch) 异步等待
  return {
    connect() {
      void sse.connect()        // 打开读流,不等待
      void ccr.initialize(epoch).then(() => {
        ccrInitialized = true
        onConnectCb?.()          // 写路径就绪 → 触发 onConnect
      })
    },
    write(msg) { return ccr.writeEvent(msg) },
    writeBatch(msgs) {
      for (const m of msgs) {
        if (closed) break        // 逐条发送,closed 时停止
        await ccr.writeEvent(m)
      }
    },
    getLastSequenceNum() { return sse.getLastSequenceNum() },
    reportState(state) { ccr.reportState(state) },
    // ...
  }
}

一个值得注意的细节:writeBatch 是逐条发送的。 不是真正的 batch POST------SerialBatchEventUploader 内部做批量化(maxBatchSize=100)。逐条 enqueue 保证顺序,uploader 自动合并。每条之间检查 closed 标志------防止 transport 关闭后继续发送部分批次。

3.4 onEpochMismatch 处理

当 CCRClient 的心跳收到 409(epoch 冲突)时,onEpochMismatch 被触发:

typescript 复制代码
onEpochMismatch: () => {
  try {
    ccr.close()
    sse.close()
    onCloseCb?.(4090)  // 4090 = epoch 被取代
  } catch { /* ... */ }
  throw new Error('epoch superseded')  // 必须 throw 以中断调用方
}

为什么必须 throw? handleEpochMismatch 的签名要求 never 返回------调用方(request())在 409 分支后继续执行会导致未定义行为。throw 确保异常传播到 uploader,uploader 将其作为发送失败处理。


第四章:客户端镜像------RemoteSessionManager

src/remote/RemoteSessionManager.ts 是远程客户端 的会话管理器。它不跑在 Bridge 里------它跑在使用 Bridge 的产品中,面向的是连接现有远程会话的"观察者"或"协作者"。

在调用全景中,RemoteSessionManager 服务于两种产品形态

  • 助理模式claude assistant,入口 ⑦):通过 useRemoteSession.ts hook 创建 RemoteSessionManager 实例,连接已有 Bridge 会话作为 viewer/controller。
  • Direct Connect (入口 ⑧):不经过 REML hook,直接通过 DirectConnectSessionManager 建立 WebSocket 连接。

它和 Bridge 是镜像关系:

维度 Bridge(本地代理) RemoteSessionManager(远程客户端)
运行位置 用户电脑 claude.ai 前端 / CLI assistant
连接方式 SSE + CCRClient(V2)/ HybridTransport(V1) SessionsWebSocket
入站消息 用户输入(从远程来) Agent 输出(从本地来)
出站消息 Agent 输出(发送到远程) 用户输入(发送到本地)
权限处理 发送 permission request 展示权限请求 + 发送响应

4.1 核心职责

typescript 复制代码
class RemoteSessionManager {
  connect()              → new SessionsWebSocket().connect()
  sendMessage(content)   → POST /v1/sessions/{id}/events
  respondToPermissionRequest(requestId, result)
                         → sendControlResponse({ behavior: 'allow'/'deny' })
  cancelSession()        → sendControlRequest({ subtype: 'interrupt' })
}

4.2 权限请求处理

当远程 Agent 需要权限确认时(比如执行 git push),权限请求通过 WebSocket 到达 RemoteSessionManager

typescript 复制代码
private handleControlRequest(request: SDKControlRequest): void {
  if (inner.subtype === 'can_use_tool') {
    this.pendingPermissionRequests.set(request_id, inner)
    this.callbacks.onPermissionRequest(inner, request_id)
  } else {
    // 不支持的 subtype → 返回 error(避免服务端悬挂等待)
    this.websocket?.sendControlResponse({
      type: 'control_response',
      response: { subtype: 'error', request_id, error: '...' }
    })
  }
}

不支持的 control_request subtype 也会被处理。 源码不会忽略未知指令------返回 error 响应,避免服务端悬挂等待 reply 直到超时杀死 WebSocket。

4.3 viewerOnly 模式

typescript 复制代码
viewerOnly?: boolean

viewerOnly = true 时(claude assistant 场景):

  • Ctrl+C/Escape 不发送 interrupt 到远程 Agent
  • 60 秒重连超时被禁用
  • 会话标题永不更新

这用于"只读观察者"场景------用户看着 Agent 工作但不干预。


第五章:完整消息流------一条远程消息的旅程

把前面四章串起来,追踪一条远程消息从 claude.ai 到本地 Agent 的完整旅程:

复制代码
┌──────────────────────────────────────────────────────────────────┐
│ ① claude.ai 前端                                                 │
│    用户输入 "fix the login bug"                                   │
│    POST /v1/sessions/{id}/events                                 │
└───────────────────────┬──────────────────────────────────────────┘
                        ▼
┌──────────────────────────────────────────────────────────────────┐
│ ② 服务端                                                         │
│    消息 → 通过 SSE 流 或 WebSocket 推送到 Bridge                  │
└───────────────────────┬──────────────────────────────────────────┘
                        ▼
┌──────────────────────────────────────────────────────────────────┐
│ ③ Bridge 收到数据帧                                               │
│    handleIngressMessage(data, ...)                                │
│    ├─ 解析 JSON → { type: "user", message: {...}, uuid: "..." }  │
│    ├─ control_response? → no                                     │
│    ├─ control_request? → no                                      │
│    ├─ SDKMessage? → yes, type === "user"                         │
│    ├─ recentPostedUUIDs.has(uuid)? → no(不是回声)               │
│    └─ recentInboundUUIDs.add(uuid) → onInboundMessage(msg)       │
└───────────────────────┬──────────────────────────────────────────┘
                        ▼
┌──────────────────────────────────────────────────────────────────┐
│ ④ 消息注入本地 Agent                                              │
│    REPL 模式:消息注入到现有消息循环中                             │
│    守护进程模式:消息写入子进程 stdin                              │
│    Agent 开始处理 "fix the login bug"                            │
└──────────────────────────────────────────────────────────────────┘

如果 Agent 执行时需要权限确认(比如要执行 git push),走一条反向路径:

复制代码
Agent 需要权限
    │
    ▼
sendControlRequest({ subtype: "can_use_tool", tool_name: "Bash", ... })
    │
    ▼
服务端 → claude.ai 网页弹窗 "Claude 想执行 git push --- 允许/拒绝?"
    │
    ▼
用户点击"允许"
    │
    ▼
服务端 → Bridge SSE 收到 control_response { behavior: "allow" }
    │
    ▼
handleIngressMessage → isSDKControlResponse → onPermissionResponse(res)
    │
    ▼
transport.reportState('running') + 通知 Agent 继续执行

权限回路不依赖轮询。 整个请求-响应过程走 WebSocket/SSE 实时推送------从用户点击到 Agent 继续执行,端到端延迟通常不到 200ms。


本章小结

消息路由层是 Bridge 的"中枢神经"。 它用三个设计保证了分布式消息系统的可靠性:

  • 回声消除BoundedUUIDSet 环形缓冲区,O(2000) 固定内存解决 SSE 全双工镜像问题
  • 去重recentInboundUUIDs 作为序列号协商的 safety net------主要防线(序列号)可能失败,但 safety net 不会
  • 超时约定:control_response 必须在 ~10-14 秒内响应------这是硬性 API 契约

传输层抽象让 V1 和 V2 的上层代码完全一致。 ReplBridgeTransport 接口只有 ~15 个方法------replBridge.tsremoteBridgeCore.ts 加起来 3000+ 行代码,没有一个地方直接引用 HybridTransportCCRClient 的具体类型。

RemoteSessionManager 和 Bridge 的镜像设计是分布式系统对称性的典范。 两者的消息格式完全一致------都使用 SDKMessage / SDKControlRequest / SDKControlResponse 协议。不同的只是方向------一个是"把远程消息注入本地",一个是"把本地消息发送到远程"。


系列导航

本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,完整版见本系列远程模式专栏。

本系列其他子篇章覆盖了 V1 环境注册与轮询架构、V2 无环境直连架构、鉴权链与会话生命周期等命题,可独立阅读。


如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

相关推荐
老王谈企服1 小时前
【2026深度洞察】制造业供应链全链路可视化,未来有哪些技术发展方向? | 实在Agent企业级解决方案
人工智能·ai
Agent产品评测局1 小时前
国产vs海外AI Agent方案,制造业场景适配性横评:2026年企业级自动化选型全景观察
运维·人工智能·ai·chatgpt·自动化
秋堂主1 小时前
Claude Code斜杠命令
cc·claude code
Wild API2 小时前
API中转站多模态接入怎么选:文本、图片、音频不要混在一起测
网络·人工智能·ai
刘一说2 小时前
AI科技热点日报 | 2026年5月11日
人工智能·ai·机器人·agent
囫囵吞桃10 小时前
Agent出现LLM因为历史工具调用消息而误解工具调用方式的问题
llm·agent
哥布林学者12 小时前
深度学习进阶(二十)Transformer-XL
机器学习·ai
极客老王说Agent13 小时前
2026智造前瞻:实在Agent生产排期智能助理核心功能与使用方法详解
大数据·人工智能·ai·chatgpt
后端小肥肠14 小时前
公众号漫画卷疯了?我用漫画工厂Skill,3天带群友入池,小白也能抄作业
人工智能·aigc·agent