双架构演进与NDJSON远程协议——Agent突破本地边界
双架构演进与NDJSON远程协议——Agent突破本地边界
《Claude Code 架构解密》精读笔记 · 第18篇 · 第13章
核心洞察:Agent远程化的关键不是"如何传输",而是"状态归谁所有"——这个决定将导出通信架构、安全模型和故障语义的全部设计。
导语:当 Agent 必须离开终端
前十二章讨论的所有机制——查询循环、工具执行、权限审批、上下文压缩——都假设用户坐在终端前,与本地CLI实时交互。但现实世界的场景远比这复杂:
- 📱 通勤时用手机查看Agent是否完成了耗时重构任务
- 🌐 在浏览器中直接向本地机器发起新的编码请求
- 🔄 同时管理多个并行的编码会话
这带来了一个根本性的架构挑战:如何将一个为本地设计的Agent系统透明地延伸到远程端,同时不降级安全保证、不丢失消息一致性?
Claude Code 的回答是 Bridge/Remote 系统——一套"语义级会话桥接"架构。本章将揭示:为什么不用SSH?v1/v2双架构如何演进?三层消息去重解决了什么问题?权限如何跨端桥接?Token生命周期如何管理?
一、三条路径的抉择:为什么不用SSH?
1.1 远程交互的三种经典方案
面对"远程控制本地Agent"的需求,架构师有三种经典选择:
| 方案 | 传输内容 | 代表产品 | 延迟敏感度 | 安全模型 |
|---|---|---|---|---|
| 终端流转发 | 字符/ANSI转义序列 | SSH、tmux、Mosh | 高(每次按键) | 全权委托 |
| UI状态同步 | 渲染后的UI组件树 | VNC、Chrome Remote Desktop | 高(帧级) | 全权委托 |
| 语义消息桥接 | 协议消息(用户输入、工具审批) | Claude Code Bridge | 低(消息级) | 可选择性审批 |
Claude Code 选择了第三条路——语义消息桥接,背后有三个深层原因:
1.2 三个不可妥协的理由
理由一:权限模型不可降级。 回顾第5章的权限系统,Claude Code的核心安全承诺是"每个危险操作都需要用户确认"。终端转发方案(如SSH)将远程端视为"完全信任的用户"——按下回车就等同于本地执行,整个权限系统被绕过。语义桥接则保留了消息级别的权限拦截点:远程容器发出 can_use_tool 控制请求,本地TUI弹出审批对话框,审批结果通过 control_response 回传。
理由二:带宽效率。 Agent对话的交互模式是"突发式"——用户发送一个请求,Agent可能执行10分钟然后返回结果。实时字符流转发(SSH/Mosh)的大部分带宽浪费在Spinner动画和进度条这些无语义信息上。语义桥接只传输有意义的消息:用户输入、工具调用、模型响应。
理由三:多端渲染自由。 语义消息是结构化数据(JSON),而非终端字符流。Web端可以用React组件渲染消息,移动端可以用原生UI渲染,不必模拟终端。这种解耦使各客户端体验独立演进。
1.3 Bridge/Remote的架构定位
Bridge/Remote系统横跨编排层(L3)和交互层(L4),是一个"横切层":
Claude Code 五层架构
┌──────────────────────────────────────────────┐
│ L5 入口层: main.tsx / cli.tsx │
│ 本地路径 ↕ 远程路径 │
├──────────────────────────────────────────────┤
│ L4 交互层: REPL.tsx ↔ useRemoteSession │
├──────────────────────────────────────────────┤
│ L3 编排层: QueryEngine ↔ RemoteSessionManager│
├──────────────────────────────────────────────┤
│ Bridge/Remote系统(横切层) │
│ src/bridge/(出站方向:本地→云端) │
│ bridgeMain.ts — 独立桥接服务器 │
│ replBridge.ts — REPL桥接核心(v1) │
│ remoteBridgeCore.ts — Env-less桥接(v2) │
│ bridgeMessaging.ts — 消息路由与去重 │
│ sessionRunner.ts — 子进程孵化器 │
│ src/remote/(入站方向:云端→本地) │
│ RemoteSessionManager.ts — 远程会话管理 │
│ SessionsWebSocket.ts — ws客户端 │
│ sdkMessageAdapter.ts — 协议适配 │
│ remotePermissionBridge.ts — 权限桥接 │
├──────────────────────────────────────────────┤
│ L1 基础设施层:API / OAuth / 状态存储 │
└──────────────────────────────────────────────┘
关键架构决策:Bridge和Remote是两个方向相反的系统。 Bridge是"把本地会话暴露给云端"(出站),Remote是"在本地消费云端会话"(入站)。它们共享消息协议和去重机制,但职责完全独立。
1.4 与主流远程方案的核心对比
| 维度 | Claude Code Bridge | VS Code Remote | GitHub Codespaces | Cursor Remote |
|---|---|---|---|---|
| 传输语义 | Agent协议消息(语义级) | 完整开发环境(容器级) | LSP+文件系统(API级) | 编辑器+AI(混合级) |
| 计算位置 | 本地执行,远程观察 | 远程执行,本地渲染 | 全远程 | 远程推理+编辑 |
| 权限模型 | 跨端审批 | 隐式信任(SSH) | 容器沙箱 | 编辑器权限 |
| 离线能力 | 断连不中断执行 | 断连即停止 | 断连即停止 | 部分可用 |
| 状态所有权 | 本地拥有 | 远程拥有 | 远程拥有 | 混合 |
💡 核心洞察:状态所有权决定通信架构。 表中最根本的差异在最后一行。VS Code Remote和Codespaces将状态放在远程,本地只是渲染层。Claude Code恰恰相反——计算留在本地,远程只是交互的另一个入口。这个决策的连锁效应是深远的:
- 断连语义不同:远程拥有状态时,断连=工作停止;本地拥有状态时,断连=远程端丢失视图,但Agent继续执行
- 一致性要求不同:远程拥有状态时,需要"可靠队列"保证每条消息到达;本地拥有状态时,"尽力同步"就够——丢几条消息不影响执行,重连后可以重新flush完整历史
- 安全模型不同:远程拥有状态时,远程端是"完全信任的执行环境";本地拥有状态时,远程端只是"受控的交互通道"
二、双架构并存:v1与v2的演进
2.1 从纯本地到分布式的三阶段
Claude Code的远程能力经历了清晰的三阶段演进:
阶段1:纯本地REPL(初始架构)
用户 → REPL → QueryEngine → [工具执行 + 权限审批]
全部在同一进程内
问题:用户必须守在终端前
阶段2:v1 Env-based Bridge(Daemon模式)
CCR后端 ← bridgeMain(持久进程)→ 环境注册 → Web/App
↓ spawn
子进程CLI
JWT管理
优势:多会话、持久化、崩溃恢复
代价:冷启动慢(注册→轮询→spawn约5-10秒)
阶段3:v2 Env-less Bridge(快速连接)
Code Session → REPL进程(同进程)→ claude.ai → API/Web/App
无需spawn,直接JWT
优势:3次HTTP请求+1次SSE建立,2秒内完成
代价:不支持多会话、不支持持久模式
为什么没有直接跳到阶段3?这是一个经典的"先做对,再做快"的架构决策。v1的Daemon模式承担了关键的验证使命——它证明了远程控制的产品价值和安全模型。权限桥接、消息去重、崩溃恢复这些核心能力是否必需?在v1中得到了回答。v2在此基础上做减法——保留已验证的核心能力,去掉REPL场景不需要的复杂性。
2.2 v1与v2的架构对比
| 维度 | v1 Env-based | v2 Env-less |
|---|---|---|
| 入口文件 | replBridge.ts | remoteBridgeCore.ts |
| 连接模型 | 注册环境→长轮询→接受工作→建传输 | 创建会话→换JWT→直接建传输 |
| 传输协议 | WebSocket或SSE | SSE + HTTP POST |
| 认证链 | OAuth→Environment Secret→Session JWT | OAuth→worker_jwt |
| 多会话 | 支持(最多32个并发) | 不支持 |
| 持久模式 | 支持(perpetual) | 不支持 |
| 适用场景 | Daemon/多会话/claude remote-control | REPL快速连接 |
| 代码规模 | ~2,400行 | ~1,000行 |
| 功能开关 | tengu_ccr_bridge | tengu_bridge_repl_v2 |
2.3 并存的代价:三处代码分叉
v1/v2并存的架构债务体现在三处代码分叉:
① 消息写入路径分叉
replBridge.ts:writeMessages()— 1,800行闭包中的62行实现remoteBridgeCore.ts:writeMessages()— 独立的45行实现- 语义相同但去重策略细节不同
② Token刷新策略分叉
- v1:
updateAccessToken→ 子进程环境变量热更新 - v2:
rebuildTransport→ 重建整个传输层
③ 条件分支蔓延
// initReplBridge.ts
if (isEnvLessBridgeEnabled() && !perpetual) // 选择 v1 还是 v2
// 但 perpetual 的判断散落在多处
⚠️ 设计模式:渐进式架构演进的陷阱。 “先做对,再做快"是正确的策略,但如果v1的某些能力无法被v2覆盖,你得到的不是"渐进式演进”,而是两套需要长期维护的代码路径。解法是在v1阶段就为未来简化预留抽象点——将"多会话管理"和"连接建立"明确分离为独立模块,使v2可以替换后者而复用前者。
2.4 v1连接建立流程
initBridgeCore(params)
① 读取崩溃恢复指针(bridge-pointer.json)
② createBridgeApiClient(baseUrl, getAccessToken, ...)
③ api.registerBridgeEnvironment(config) → environmentId + secret
④ [perpetual] tryReconnectInPlace(prior.envId, prior.sessionId)
⑤ createSession({environmentId, title, ...}) → sessionId
⑥ writeBridgePointer(dir, {sessionId, environmentId})
⑦ startWorkPollLoop(pollOpts) ← 核心轮询循环
pollForWork() → work → decodeWorkSecret() → acknowledgeWork()
useCcrV2? → createV2ReplTransport(SSE + CCR)
v1? → createV1ReplTransport(HybridTransport/WS)
wireTransport → onConnect → flushHistory → ready
⑧ registerCleanup(teardown)
步骤⑦的startWorkPollLoop是一个长轮询循环:Bridge向CCR后端发送pollForWork请求,后端挂起连接直到有新的远程工作到来(或超时)。这种"服务端推送"模式虽然增加了连接建立的延迟,但避免了维护持久WebSocket连接的复杂性。
2.5 v2连接建立流程
v2的设计哲学是"能省则省"——去掉环境注册、长轮询和子进程:
initEnvLessBridgeCore(params)
① getEnvLessBridgeConfig() // GrowthBook 配置
② withRetry(createCodeSession) // POST → session_id
③ withRetry(fetchRemoteCredentials) // POST → worker_jwt + epoch
④ buildCCRv2SdkUrl(api_base_url, sessionId)
⑤ createV2ReplTransport(SSE + CCRClient)
⑥ 初始化去重Sets + FlushGate
⑦ createTokenRefreshScheduler(5min buffer)
⑧ wireTransportCallbacks()
⑨ transport.connect() // SSE + CCR initialize
⑩ 返回 ReplBridgeHandle
从调用到连接可用只需3次HTTP请求+1次SSE建立,冷启动时间从v1的5-10秒降低到2秒以内。代价是放弃了多会话和持久模式——典型的"场景聚焦"Trade-off。
三、消息传输架构:三层去重的精巧设计
3.1 消息流向拓扑
消息在两个方向上流动,每个方向有独立的处理管线:
出站(本地→云端):
REPL消息更新(useEffect)
→ Bridge层过滤+转换
→ isEligibleBridgeMessage → toSDKMessages
→ UUID去重
→ /worker/events (HTTP POST)
入站(云端→本地):
SSE/WebSocket事件流
→ 控制请求路由
→ handleIngressMessage
→ Echo过滤
→ Re-delivery去重
→ REPL渲染(setMessages)
3.2 出站消息过滤
不是所有本地消息都值得同步到远程端。bridgeMessaging.ts中的isEligibleBridgeMessage函数扮演了"消息海关":
| 消息类型 | 是否转发 | 原因 |
|---|---|---|
user |
✅ | 用户输入是对话核心 |
assistant |
✅ | AI响应需要远程可见 |
system (subtype: local_command) |
✅ | 斜杠命令结果需同步 |
system (其他) |
❌ | 内部系统消息无需远程展示 |
| 虚拟消息(synthetic) | ❌ | 本地渲染辅助,非实际对话 |
💡 这个过滤策略体现了语义桥接的核心优势——只传输有业务意义的消息,大幅减少网络开销。大量的Spinner更新、进度条和虚拟分隔线都被过滤掉了。
3.3 三层消息去重
消息去重是Bridge系统最精巧的设计之一。为什么需要三层? 因为重复消息来自三个不同的来源,每个来源需要不同的去重策略。
第一层:序列号续传(Sequence Number)
SSE传输支持from_sequence_num/Last-Event-ID,Transport重建时继承lastTransportSequenceNum,防止服务器重放完整历史:
旧Transport(seq=42)—close—→ 新Transport connect(from_sequence_num=42)
→ 服务器从seq=43开始发送
第二层:BoundedUUIDSet(环形缓冲区去重)
序列号解决了传输层重放,但还有另一种重复:你发出的消息被服务器Echo回来。BoundedUUIDSet用环形缓冲区+Set组合解决:
class BoundedUUIDSet {
private ring: (string | undefined)[] // 固定容量2000的环形数组
private set = new Set<string>() // O(1)查找
private writeIdx = 0
add(uuid: string): void {
if (this.set.has(uuid)) return
// 驱逐最旧的UUID,为新UUID腾出空间
const evicted = this.ring[this.writeIdx]
if (evicted) 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会随时间无限膨胀;LRU Cache又太重。环形数组+Set的组合用O(capacity)的固定内存实现了O(1)的查找和插入,最旧条目自动被驱逐。容量设为2000,足以覆盖一个典型会话中的往返消息数量。
系统实际维护了两个独立的BoundedUUIDSet:
recentPostedUUIDs:记录自己发出的消息UUID,过滤Echo回来的副本recentInboundUUIDs:记录收到的入站消息UUID,防御传输切换时的重复投递
v2还额外维护一个initialMessageUUIDs(普通Set),用于兜底初始flush阶段的UUID去重。
第三层:FlushGate门控
前两层解决了单条消息的去重,但还有一个更微妙的问题:批量同步与增量同步的交错。
FlushGate时序:
时间 ─────────────────────────────────────────────→
闸门关闭 闸门打开
[flush → drain →]
历史消息 新消息进入队列 排空队列 正常传输
[批量发送] [暂时缓冲] [按序发出] [实时]
当Transport首次连接(或重建)时,Bridge需要将历史消息批量flush到远程端。在flush进行期间,新的实时消息可能已经开始到达。如果不加控制,这些实时消息可能插入到历史消息中间,导致时序错乱。FlushGate的解法是:
- 初始历史flush开始时,闸门关闭——新消息进入等待队列
- flush完成后,闸门打开——排空队列中的等待消息,恢复正常实时传输
- Transport重建时也启动FlushGate,防止旧epoch的消息被静默丢弃
💡 设计模式:分层去重。 当消息重复来自多个独立来源(传输层重放、Echo回环、批量/增量交错)时,一层去重无法覆盖所有情况。正确的做法是:先枚举所有可能的重复来源,再为每个来源设计对应的去重策略。层与层之间互不依赖,任何一层失效不影响其他层的防护。
3.4 为什么不用消息队列?
| 方案 | 优势 | 劣势 |
|---|---|---|
| 当前:尽力同步 | 低延迟、无持久化开销、代码简单 | Transport断开时消息丢弃 |
| 替代:本地消息队列 | 消息不丢失、断线重传 | 需持久化、需幂等性保证、复杂度高 |
当前设计的合理性在于:Bridge同步的是REPL会话的"实时视图",不是关键业务数据。即使丢失几条消息,用户在Transport恢复后仍能通过重新flush历史消息获得完整视图。writeMessages函数在无transport时直接丢弃并记录警告,明确接受了这个Trade-off。
这是"状态归本地所有"的又一个推论——既然远程端只是"视图",那视图短暂不完整是可以接受的,不值得为此引入持久化消息队列的复杂性。
四、会话生命周期管理
4.1 独立桥接模式:bridgeMain.ts
bridgeMain.ts是一个近3000行的"微型会话调度器",作为独立的Daemon进程运行,管理多个远程会话的完整生命周期:
bridgeMain()启动流程:
参数解析 → OAuth认证 → 信任检查
→ 选择spawn模式:
single-session:一个会话,结束即退出
same-dir:多会话共享目录
worktree:多会话各自git worktree隔离
→ 注册环境 → POST /v1/environments/bridge
→ 预创建空会话 → createBridgeSession()
→ 进入runBridgeLoop():
pollForWork() → 长轮询获取工作项
decodeWorkSecret() → 提取JWT
acknowledgeWork() → 确认接受
safeSpawn() → sessionRunner创建子进程
子进程: claude --print --sdk-url <url>
--input-format stream-json
--output-format stream-json
--replay-user-messages
stdout → NDJSON解析 → 活动状态提取
stdin → Token刷新(update_environment_variables)
stderr → 环形缓冲区(最近10行诊断)
spawn模式值得关注:worktree模式为每个远程会话创建独立的git worktree,实现了文件系统级别的隔离——多个远程用户可以同时在同一仓库的不同分支上工作,互不干扰。这与第6章讨论的Agent编排中的fork-and-delegate模式异曲同工。
4.2 容量管理与心跳模式
当并发会话达到上限(默认32,受GrowthBook Feature Flag控制)时,Bridge不是简单地拒绝新连接,而是切换到"心跳模式":
容量满载时的行为切换:
正常模式:pollForWork() → 获取新工作 → spawn子进程
↓ activeSessions.size >= maxSessions
心跳模式:heartbeatActiveWorkItems() → 维持环境存活
→ 某会话结束 → capacityWake.signal()
正常模式:立即恢复pollForWork()
为什么需要心跳?如果Bridge停止轮询,CCR后端会认为环境已死亡并回收它。心跳模式通过定期调用heartbeatWork()保持环境存活,同时不接受新工作。
容量唤醒机制(capacityWake)是另一个精巧的设计:当任何会话结束时,capacityWake.signal()会立即中断心跳模式的sleep,让Bridge尽快回到pollForWork接受新工作。这避免了用户不必要的等待。
4.3 崩溃恢复:双策略恢复
对于Daemon模式的Bridge,进程崩溃是现实威胁。Bridge使用"指针文件+双策略恢复"来应对:
崩溃恢复链路:
写入指针: writeBridgePointer(dir, { sessionId, environmentId, source })
→ 在会话创建后立即写入
→ 每小时刷新mtime
→ 4小时TTL过期自动删除
读取指针: readBridgePointerAcrossWorktrees()
→ --continue参数触发
→ 扫描当前目录及所有git worktree
恢复流程:
→ 策略1:原地重连(reconnect-in-place)
reuseEnvironmentId → 后端返回相同envId → reconnectSession()
优势:快速,复用旧会话UUID和历史消息
→ 策略2:新建会话(fresh session fallback)
后端返回不同envId → archiveSession(旧) → createSession(新)
触发条件:环境已被后端GC(4小时后)
双策略恢复体现了一个通用模式:乐观尝试+悲观兜底。先尝试最快的恢复路径(原地重连),如果失败则自动fallback到更安全但更慢的路径(新建会话)。
4.4 优雅关机
Bridge的关机不是简单的process.exit(),而是一个有序的teardown序列:
teardown()流程(非perpetual模式):
① transport.write(makeResultMessage) ← 通知服务端会话结束
② api.stopWork(force=true) ← 释放工作项
③ archiveSession(currentSessionId) ← 归档会话
④ transport.close() ← 关闭传输
⑤ api.deregisterEnvironment(envId) ← 注销环境
⑥ clearBridgePointer(dir) ← 清理指针文件
持久模式(perpetual)的差异值得注意——“选择性不清理”:
- 不发送result → 服务端不归档会话(会话保持"活跃")
- 不注销环境 → 环境保持存活(依赖300秒leaseTTL自动回收)
- 不清理指针 → 下次
--continue可以恢复
这种设计让持久模式的Bridge可以在重启后快速恢复上一个会话。代价是如果Bridge进程意外消失,环境会在300秒后被TTL自动回收——合理的超时窗口。
五、Remote系统:消费端架构
5.1 四种远程模式
| 模式 | 实现 | 用例 |
|---|---|---|
| –remote (CCR云端会话) | RemoteSessionManager → SessionsWebSocket + HTTP POST | 连接到远程运行的Claude Code会话 |
| claude assistant (CCR viewerOnly) | 同上,但禁用interrupt/标题更新/超时检测 | 只读观察远程会话 |
| claude connect (Direct Connect) | DirectConnectManager → 类似API,连接本地服务器 | IDE集成/本地多客户端 |
| claudessh (SSH子进程) | SSH tunnel → stdin/stdout传输 | 通过SSH隧道连接远程机器 |
REPL通过统一抽象选择当前生效的远程后端:
const activeRemote = sshRemote.isRemoteMode
? sshRemote
: directConnect.isRemoteMode
? directConnect
: remoteSession
四种模式在回调形状(onMessage、sendInput、onPermissionRequest等)上保持一致,使REPL不需要关心消息实际来自哪种远程后端——依赖注入模式的又一个应用。
5.2 WebSocket断线重连策略
不同的关闭原因对应不同的重连行为:
| 关闭码 | 含义 | 重连策略 |
|---|---|---|
| 4003 | Unauthorized | 永久关闭,不重连 |
| 4001 | Session not found | 独立重试最多3次,递增延迟 |
| 其他 | 网络/服务器错误 | 仅在之前连接成功时重试,最多5次 |
4001有独立重试计数器的设计值得特别关注:这是为了应对"compaction期间服务器短暂认为会话不存在"的瞬态错误。如果4001共用通用重连预算,一次compaction导致的3次4001可能耗尽预算,后续真正的网络错误就无法重连了。
5.3 权限跨端桥接:最精妙的设计
权限桥接是Remote系统最精妙的设计——远程容器需要执行工具,但权限确认必须在本地TUI中弹出:
权限桥接流程:
CCR容器执行工具
→ ws发送 control_request { subtype:'can_use_tool', tool_name, input }
→ RemoteSessionManager.handleControlRequest()
→ pendingPermissionRequests.set(request_id, request)
→ onPermissionRequest(request, request_id)
→ useRemoteSession.onPermissionRequest()
→ findToolByName(tools, tool_name)
→ createToolStub(tool_name) ← 关键:工具桩
→ createSyntheticAssistantMessage( ← 关键:合成消息
request, requestId)
→ 入队 ToolUseConfirm → 本地TUI弹出审批对话框
用户本地审批
→ onAllow(updatedInput)
→ manager.respondToPermissionRequest(
requestId, { behavior: 'allow' })
→ SessionsWebSocket.sendControlResponse(response)
→ ws.send() → 回传到CCR容器
两个关键技巧:
① createToolStub:为远程工具创建一个最小的本地Tool桩。本地可能没有这个工具的完整定义(远程端可能安装了不同的工具集),但权限UI只需要工具名称和输入参数就能展示审批对话框。桩对象提供了"刚好够用"的接口,避免了在两端同步完整工具注册表。
② createSyntheticAssistantMessage:构造一条"假装来自本地Agent"的AssistantMessage。这条合成消息包含工具调用信息,让权限审批UI与本地工具调用的UI保持完全一致。用户在审批远程工具调用时,看到的界面和审批本地工具调用完全相同——这种"透明化"是Bridge架构的核心价值。
💡 设计模式:合成消息统一入口。 当需要在不同上下文(本地/远程)中复用同一交互流程时,可以通过构造"合成消息"将外部事件伪装成内部事件,共享同一处理管线和UI路径。这避免了为远程场景开发独立的交互流,降低了维护成本并保证了体验一致性。
六、Token生命周期管理
6.1 主动刷新调度器
Bridge不等Token过期后再刷新,而是提前5分钟主动刷新:
createTokenRefreshScheduler({ refreshBufferMs = 5min })
schedule(sessionId, jwt):
→ 解码JWT exp claim
→ 计算 delayMs = (exp - now - 5min)
- delayMs ≤ 0:立即刷新
- delayMs > 0: setTimeout(doRefresh, delayMs)
doRefresh(sessionId):
v1: handle.updateAccessToken(oauthToken)
→ 通过子进程stdin发送update_environment_variables
→ 特点:热更新,子进程不中断
v2: api.reconnectSession(envId, sessionId)
→ 让服务端重派带新JWT的工作项
→ 特点:冷更新,触发rebuildTransport
v1与v2的刷新方式差异值得深思:
- v1是"热更新"——通过子进程stdin注入新的环境变量,子进程无需中断
- v2是"冷更新"——触发rebuildTransport,重新建立SSE连接
- v2的方式更安全(新JWT直接用于新连接,不存在旧JWT残留的风险),但有短暂的断连窗口
6.2 OAuth 401恢复链
当SSE/WebSocket收到401错误,触发精心设计的恢复链:
SSE/WS收到401
→ recoverFromAuthFailure():
→ 检查authRecoveryInFlight(防重入)
→ onAuth401(staleToken) → 刷新OAuth Token
→ fetchRemoteCredentials() → 新worker_jwt + epoch
→ rebuildTransport(newCredentials, 'auth_recovery')
→ seq = transport.getLastSequenceNum() // 继承序列号
→ transport.close()
→ transport = createV2ReplTransport({...})
→ wireTransportCallbacks()
→ transport.connect()
→ drainFlushGate() ← 排空等待中的消息
关键竞态防护:authRecoveryInFlight标志用于串行化401恢复与定时刷新。考虑以下场景:
- Token在5分钟缓冲期内过期(可能时钟偏移导致)
- SSE触发401恢复
- 同时,定时刷新也被触发
如果两者并发执行,可能双发/bridge请求导致epoch不一致(HTTP 409冲突)。authRecoveryInFlight确保同一时刻只有一个恢复流程在执行。
6.3 跨进程退避
initReplBridge.ts中还实现了更高层次的保护——跨进程的OAuth退避:
跨进程退避机制:
进程A:OAuth刷新彻底失败
→ 写入GlobalConfig:
bridgeOauthDeadExpiresAt = now + backoff_period
bridgeOauthDeadFailCount = count + 1
进程B:准备刷新OAuth
→ 读取GlobalConfig
→ if (now < bridgeOauthDeadExpiresAt)
→ 跳过刷新,避免重复尝试
这种"通过共享配置实现跨进程协调"的模式虽然简单,但在实践中非常有效——防止了多个Bridge进程同时反复尝试失败的OAuth刷新。
七、子进程孵化与NDJSON协议
7.1 子进程通信模型
在v1模式下,每个远程会话对应一个本地CLI子进程。sessionRunner.ts使用NDJSON协议通过stdio通信:
桥接主进程 —stdin→ 子进程
update_environment_variables (Token刷新)
claude --print --sdk-url <url>
--session-id <id>
--input-format stream-json
--output-format stream-json
子进程 —stdout→ 桥接主进程
assistant(工具调用/文本响应)
user(用户消息replay)
result(会话结束信号)
control_request(权限请求)
子进程 —stderr→ 环形缓冲区(10行)
为什么选择NDJSON over stdio而不是其他IPC方式?
- 跨平台:stdio在所有操作系统上都可用,Unix socket在Windows上不原生支持
- 进程隔离:子进程崩溃不会拖主进程(共享内存有此风险)
- 可调试:NDJSON是文本协议,stderr输出可以直接阅读
- 自然的流控:stdio pipe有内核级别的缓冲区,天然提供背压控制
7.2 活动状态提取
Bridge主进程通过解析子进程stdout中的NDJSON消息提取活动信息:
子进程stdout → NDJSON解析器 → 消息分类
assistant消息 → 提取工具调用状态
tool_use block? → 更新"正在执行 <tool_name>"
text block? → 更新"正在思考..."
result消息 → 会话结束信号
→ 触发会话归档 + 工作项释放
control_request消息 → 权限请求
→ 路由到远程端的权限审批UI
7.3 进程终止策略
优雅关机序列:
① SIGTERM → 子进程 ← 请求优雅退出
② 等待graceful窗口(30s) ← 给子进程时间清理
③ SIGKILL → 顽固子进程 ← 强制终止
④ archiveSession() ← 归档会话
⑤ removeAgentWorktree() ← 清理git worktree(若有)
⑥ stopWork() ← 释放工作项
30秒的优雅窗口是深思熟虑的选择:Claude Code的工具执行(如git commit、npm install)可能需要几秒钟完成,但不太可能超过30秒。Windows平台有特殊情况——child.kill()不支持信号名称参数,SIGTERM和SIGKILL的行为可能相同,Bridge通过"先试后杀"的双阶段策略来应对。
八、控制协议:云端与本地的精确交互
8.1 控制请求类型
| 方向 | Subtype | 语义 | 超时 |
|---|---|---|---|
| Server→Client | initialize | 会话初始化,返回能力列表 | ~10-14s |
| Server→Client | set_model | 切换当前模型 | ~10-14s |
| Server→Client | set_max_thinking_tokens | 调整思考Token上限 | ~10-14s |
| Server→Client | set_permission_mode | 切换权限模式 | ~10-14s |
| Server→Client | interrupt | 中断当前turn | ~10-14s |
| Client→Server | can_use_tool | 工具使用权限请求 | 用户操作 |
两个不对称性:
-
方向不对称:大部分控制请求是服务端(远程)发给客户端(本地),只有
can_use_tool是反方向的。这反映了Bridge的核心原则——远程端是"控制器",本地端是"执行器+审批者"。 -
超时不对称:系统控制请求有固定超时(10-14秒),而权限请求的超时取决于用户操作。系统控制是自动化的(应该很快完成),而权限审批需要等待人类决策。
8.2 超时防御
服务器发送control_request后,如果客户端未在超时窗口内响应,服务器将断开WebSocket连接。handleServerControlRequest对此有一个防御性设计:
function handleServerControlRequest(request) {
switch (request.subtype) {
case 'initialize': return handleInitialize(request)
case 'set_model': return handleSetModel(request)
// ... 其他已知类型
default:
// 未知类型也返回error,防止超时断连
return { type: 'error', message: `Unknown subtype: ${request.subtype}` }
}
}
未知请求类型在协议演进中是常见的(服务端升级后可能发送新类型的控制请求),静默忽略会导致超时断连,而返回error至少保持了连接的存活。
8.3 outboundOnly模式
当outboundOnly = true时(CCR Mirror模式),除initialize外所有可变请求返回error。使用场景是"展示/演示"——你可能想让别人看到你的编码过程,但不希望他们能中断你的执行或切换模型。
九、横向对比:Bridge/Remote的架构评价
9.1 值得学习的四个模式
| 模式 | 解决的问题 | 核心思想 |
|---|---|---|
| 有界幂等性检查(BoundedUUIDSet) | 有界去重,内存不无限增长 | 环形缓冲区+Set,O(capacity)内存,O(1)操作 |
| 冷热路径切换门控(FlushGate) | 批量与增量同步的交错排序 | 先缓冲后排空,保证消息时序 |
| 合成消息统一入口 | 跨上下文复用交互流程 | 伪装外部事件为内部事件,共享UI和逻辑路径 |
| 双策略恢复 | 快速恢复+安全兜底 | 乐观尝试+悲观fallback,先快后稳 |
9.2 值得警惕的四个架构问题
① 巨型闭包风险initBridgeCore函数体超过1,800行,内部闭包变量20+个。状态散布在transport、currentWorkId、lastTransportSequenceNum、flushGate等变量中,没有显式状态机。对比第3章的QueryEngine——通过AppStateStore将状态外部化,使状态可被观测和测试。Bridge缺少这种外部化,调试困难。
② 缺乏单元测试
bridgeMain.ts、replBridge.ts、remoteBridgeCore.ts、SessionsWebSocket.ts均无直接单元测试文件。对比第8章讨论的安全系统——每个Validator都有独立的可测试边界。Bridge的核心逻辑缺乏这种可测试性设计。
③ v1/v2分叉的债务积累
消息写入、去重、teardown逻辑在两个核心文件中各有一份实现,维护代价随时间线性增长。
④ 重连策略不一致
Bridge侧使用指数退避(2s→4s→60s),Remote侧使用固定延迟(2s×5次),缺乏统一的退避策略抽象。对比第12章讨论的API层统一withRetry引擎——Bridge/Remote系统缺少同等级别的退避策略复用。
十、实战启示:Agent远程化的五条建议
综合整个Bridge/Remote系统的架构分析,以下五条洞察对任何Agent远程化设计者都有参考价值:
洞察1:先问"状态归谁所有",再决定通信架构。
Bridge的所有设计决策都源于一个根本答案:状态属于本地。正是这个决定导出了"语义桥接而非终端转发"、“尽力同步而非可靠队列”、“断连不中断执行"等一系列推论。如果你的Agent选择"状态属于云端”,整个通信架构将完全不同。
洞察2:权限模型决定了远程化的天花板。
如果你的Agent需要用户逐个审批工具调用,那远程化方案必须保留"请求→审批→执行"的三步结构,需要双向控制协议。如果你的Agent是完全自主的(如CI中的自动修复),远程化可以简化为单向的状态通知。
洞察3:消息去重必须分层,因为重复来源不同。
一层去重无法覆盖所有来源。设计远程通信时,先枚举所有可能的重复来源(传输重放、Echo回环、批量/增量交错),再为每个来源设计对应的去重策略。
洞察4:消息协议应从第一天就考虑去重。
Bridge的三层去重部分是后加的补丁。如果协议从一开始就定义全局单调序列号和幂等性语义,可以用一层解决三层的问题。教训是:不要在应用层修补传输层的不可靠性,而是在协议层就定义清楚。
洞察5:远程端应该是"观众",而非"演员"。
将远程端设计为只读观察者+受限的控制通道,而非完全对等的执行端。这简化了一致性问题(只有一个写入者),降低了网络分区的影响(远程端断连不影响执行),也使权限模型更清晰(本地是唯一的信任锚点)。
十一大设计模式速查
| 模式 | 核心思想 | 本章出处 |
|---|---|---|
| 状态所有权决定通信架构 | 远程化方案的第一个架构决策 | §13.1.3 |
| 有界幂等性检查(BoundedUUIDSet) | 固定内存的O(1)去重 | §13.3.3 |
| 冷热路径切换门控(FlushGate) | 批量与增量同步的交错排序 | §13.3.3 |
| 合成消息统一入口 | 跨上下文复用交互流程 | §13.5.3 |
| 双策略恢复 | 快速恢复+安全兜底 | §13.4.3 |
| 分层去重 | 多来源消息重复的分治防御 | §13.3.3 |
| 渐进式架构演进的陷阱 | 版本并存的债务管理 | §13.2.3 |
| 心跳模式保活 | 容量满载时的优雅降级 | §13.4.2 |
| 防御性控制请求处理 | 未知subtype返回error而非静默 | §13.8.2 |
| 跨进程退避 | 共享配置实现跨进程协调 | §13.6.3 |
| 选择性不清理 | 持久模式的快速恢复设计 | §13.4.4 |
下期预告
第19篇将进入附录A+C:22个架构模式速查 + System Prompt全链路拆解。
- 附录A:22个架构模式按场景分类速查
- 分布式任务类、状态管理类、安全防御类、扩展接入类、上下文管理类、容错弹性类
- 附录C:System Prompt全链路
- 三层装配流程(静态数据→动态注入→用户指令)
- 优先级计算:buildEffectiveSystemPrompt
- CLAUDE.md编写实战指南
从"远程突破"回到"模式总结"——13章的架构深度探索后,是时候退后一步,把散落各处的设计模式系统化地收拢了。
思考题:如果Claude Code选择"状态属于云端"(类似Codespaces模式),Bridge/Remote的架构会如何变化?哪些当前的设计决策(尽力同步、FlushGate、权限桥接)会被替换?会引入哪些新的挑战?
更多推荐


所有评论(0)