19 KiB
BidiAppend / RunSSE 原服务架构推断
本文根据实际抓包、已提取的 protobuf 定义以及同一 conversation_id 下的消息关联关系,分析 Cursor Agent 原服务采用的通信技术、运行方式和可能的服务架构。
本文只描述协议事实和架构推断,不描述当前项目的实现方案。
1. 分析样本
本次分析使用以下会话:
conversation_id: 9b31772c-35fe-4b51-a862-c177749854af
最初分析快照包含两个独立 turn;随后该会话又新增第三个 turn。以下表格保留最初两轮的详细帧统计,第三轮的 KV 细节在 KV 专项文档中单独记录:
| Turn | request_id |
RunSSE 时长 | RunSSE 帧数 | 上行消息 |
|---|---|---|---|---|
| 1 | 2faaa6b5-6ad7-4428-85f7-8cfc0cb3e52e |
24,128 ms | 24 | 1 个 run_request、4 个 heartbeat、9 个 KV 响应 |
| 2 | feac27a2-3baf-4153-b009-2809dc9d4cf2 |
3,294 ms | 24 | 1 个 run_request、8 个 KV 响应 |
两个 turn 使用相同的 conversation_id,但分别使用新的 request_id。这说明 conversation_id 表示跨 turn 的持久会话,而 request_id 表示一次活动请求流或一次 turn 的运行实例。
2. 核心结论
该通信方式可以概括为:
Connect RPC + Protobuf 实现的 split-duplex streaming,上层运行 request 维度的 Agent Actor / Workflow 状态机。
它不是 WebSocket,也不是标准 gRPC 双向流。虽然 RunSSE 响应头使用 text/event-stream,但正文不是传统 SSE 的 data: 文本事件,而是 Connect 的二进制流式帧。
从应用语义看,它将逻辑双向流拆成两个方向相反的 HTTP 通道:
BidiAppend:客户端通过多个 unary RPC 向服务端发送命令、心跳和本地执行结果。RunSSE:服务端通过一条 server-streaming RPC 向客户端发送增量事件、工具请求、状态 checkpoint 和流终态。
两条通道通过相同的 request_id 关联,共同构成应用层的双向通信。
3. 传输技术
3.1 Connect RPC
抓包中的请求头包含:
Connect-Protocol-Version: 1
User-Agent: connect-es/1.6.1
这说明桌面客户端的协议调用层使用 Connect-ES。它很可能运行在 Cursor 的 Electron / VS Code JavaScript 环境中。
BidiAppend 使用:
Content-Type: application/proto
这是一个 protobuf unary RPC。每次请求只追加一条 AgentClientMessage,服务端返回空的 BidiAppendResponse 作为接收确认。
RunSSE 请求使用:
Content-Type: application/connect+proto
Connect-Accept-Encoding: gzip
Connect-Content-Encoding: gzip
响应使用:
Content-Type: text/event-stream
Connect-Content-Encoding: gzip
text/event-stream 在这里是兼容性响应类型,实际正文仍使用 Connect 二进制 envelope。因此不能使用标准 EventSource 文本解析器处理该响应。
3.2 Connect 流式帧
每个 RunSSE 消息使用以下帧结构:
+------------+----------------------+--------------------+
| flags: 1B | length: uint32 BE | payload: length B |
+------------+----------------------+--------------------+
已观察到的 flags:
| flags | 功能 |
|---|---|
0x00 |
未压缩的 protobuf 数据帧 |
0x01 |
压缩的数据帧 |
0x02 |
EndStream 终态帧 |
小型 heartbeat 和 token 增量通常使用 0x00,体积较大的 KV 或 checkpoint 消息可能使用 0x01。两个样本的最后一帧都是 0x02。
底层连接可以运行在 HTTP/1.1 chunked response 或 HTTP/2 stream 上。当前抓包不足以确定客户端到原服务实际使用了哪一个 HTTP 版本。
4. 一次 Turn 的运行时序
抓包顺序表明客户端通常先建立 RunSSE,再通过 BidiAppend 发送 run_request。这样可以在启动 Agent Run 之前准备好下行订阅,避免遗漏早期事件。
sequenceDiagram
participant Client as Cursor Client
participant Gateway as API Gateway
participant Actor as Request Actor
participant Provider as Model Provider
Client->>Gateway: RunSSE(request_id)
Gateway->>Actor: Subscribe(request_id)
Client->>Gateway: BidiAppend(run_request)
Gateway->>Actor: Start turn
Actor->>Provider: Start model call
Provider-->>Actor: Thinking / token / tool deltas
Actor-->>Client: AgentServerMessage stream
Actor-->>Client: KV / Exec / Interaction request
Client->>Actor: BidiAppend(result, append_seqno)
Actor->>Provider: Resume with external result
Actor-->>Client: Conversation checkpoint
Actor-->>Client: EndStream
完整生命周期为:
- 客户端生成本次 turn 的
request_id。 - 客户端使用
BidiRequestId建立 RunSSE 下行流。 - 客户端通过 BidiAppend 发送
run_request。 - 服务端启动模型调用并持续发送
thinking_delta、text_delta、token_delta和 step 状态。 - 服务端需要客户端能力时,通过 RunSSE 发送 KV、Exec 或 Interaction 请求。
- 客户端执行本地操作,并通过 BidiAppend 返回对应结果。
- 服务端根据外部结果继续模型循环,或者进入 turn 收口阶段。
- 服务端同步 checkpoint 及其 blob。
- 服务端发送 EndStream,结束本次
request_id对应的流。
5. 上行顺序与幂等语义
BidiAppendRequest.append_seqno 是同一个 request_id 内的有序序号。
第一个 turn 中观察到:
run_request append_seqno = 0(字段使用默认值)
client_heartbeat append_seqno = 1..4
kv_client_message append_seqno = 5..13
KV 响应对应的 HTTP 请求在抓包记录中并不完全按照序号排列,说明客户端可能并发发起多个 BidiAppend 请求。服务端必须按 append_seqno 排序、串行处理或拒绝过期消息,不能依赖 HTTP 请求的到达顺序。
因此 append_seqno 至少承担以下功能:
- 确定同一个请求流内的命令顺序。
- 识别重复提交或重试。
- 在多个并发 unary 请求之间恢复确定性处理顺序。
它不是整个 conversation 的全局序号。新的 request_id 可以重新从较小的序号开始。
6. 标识符与状态边界
6.1 conversation_id
conversation_id 是跨 turn 的持久会话标识。它关联历史消息、checkpoint、token 状态、模式以及 workspace 元数据。
样本中的第二个 run_request 已携带第一轮产生的 conversation_state,证明 conversation 状态会跨 request_id 延续。
6.2 request_id
request_id 是活动流、一次 turn 或一次运行尝试的路由键。它同时出现在:
- RunSSE 订阅请求中。
- BidiAppend 外层请求中。
X-Request-IdHTTP 请求头中。- 本次 turn 的服务端事件和客户端结果关联关系中。
服务端需要以 request_id 找到正在运行的 Actor、事件 backlog、订阅者以及待处理的工具调用。
6.3 run_id
本次两个样本中的 run_id 与各自的 request_id 相同,但协议中它们是独立字段。架构设计不应假定两者永久等值:
request_id偏向传输和活动流路由。run_id偏向 Agent 执行实例。
6.4 KV id
KvServerMessage.id 与 KvClientMessage.id 构成一次服务端到客户端 RPC 的关联键。它与 append_seqno 的职责不同:
- KV
id关联某个具体请求和响应。 append_seqno规定所有上行消息的处理顺序。
7. Checkpoint 与 Blob 同步
协议中的 KV 虽然以 Key-Value 命名,但它表达的不是普通配置项或业务数据库。它更接近一个由客户端提供的内容寻址 Blob Store(Content-Addressable Store,CAS),用于保存和恢复 conversation checkpoint 的组成部分。
7.1 KV 消息语义
服务端通过 RunSSE 发起 KV 操作:
| 消息 | 参数 | 功能 |
|---|---|---|
get_blob_args |
blob_id |
要求客户端返回此前保存的 Blob。 |
set_blob_args |
blob_id、blob_data |
要求客户端保存指定 Blob。 |
客户端通过 BidiAppend 返回操作结果:
| 消息 | 参数 | 功能 |
|---|---|---|
get_blob_result |
blob_data 或 error |
返回 Blob 内容或读取错误。 |
set_blob_result |
可选 error |
确认保存成功,或返回写入错误。 |
KV 消息中存在两类用途不同的 ID:
KvServerMessage.id:本次 KV 操作的临时流水号,客户端使用相同值返回KvClientMessage。blob_id:Blob 内容的稳定地址,用来在 checkpoint 和其他协议消息中引用内容。
对该会话中全部 16 个 set_blob_args 进行校验后,每一个 blob_id 都精确等于对应 blob_data 的 SHA-256。由此可以确认这里使用的是内容寻址,而不是随机生成的 KV key:
blob_id = SHA-256(blob_data)
相同内容必然得到相同 blob_id,内容发生任何改变都会生成新的 ID。因此 Blob 可以被视为不可变对象,重复写入同一 Blob 也天然具有幂等性。
7.2 Blob 表达的内容
Blob 主要承载 conversation checkpoint 中体积较大、可以独立复用的 protobuf 节点,例如:
- 用户消息。
- Thinking、Assistant Message 和 ToolCall 等 conversation step。
- Conversation turn。
- Prompt context usage snapshot。
- Rules、Skills、Subagents、MCP 等大型请求上下文。
- 其他通过
blob_id、data_blob_id或content_blob_id引用的二进制内容。
Checkpoint 本身更接近一个引用清单。会话历史可以形成如下内容寻址对象图:
ConversationStateStructure
└─ turns[]: blob_id
└─ ConversationTurnStructure
├─ user_message: blob_id
└─ steps[]: blob_id
├─ ThinkingMessage
├─ AssistantMessage
└─ ToolCall
顶层 checkpoint 不必反复内嵌完整历史,只需要保存根引用。Turn Blob 再引用 UserMessage Blob 和多个 Step Blob。这种结构类似一棵由 SHA-256 连接的不可变 Merkle DAG。
7.3 写入与读取流程
Turn 结束或状态发生重要变化时,Blob 写入流程为:
- 服务端将用户消息、conversation step 和 turn 等节点分别序列化。
- 服务端对每个序列化结果计算 SHA-256,得到
blob_id。 - 服务端通过 RunSSE 发送
set_blob_args。 - 客户端保存 Blob,并通过 BidiAppend 返回
set_blob_result。 - 必要 Blob 全部确认后,服务端发送引用这些 Blob 的
conversation_checkpoint_update。 - 服务端完成本次 turn 并发送 EndStream。
下一轮恢复状态时,Blob 读取流程通常为:
- 客户端将上一轮 checkpoint 随
run_request发回。 - 服务端读取 checkpoint 和
request_context_parts中的 Blob 引用。 - 服务端按需通过 RunSSE 发送
get_blob_args请求自己当前缺少的内容。 - 客户端通过 BidiAppend 返回
get_blob_result。 - 服务端使用已持有或刚读取的 Blob 恢复所需上下文并继续运行。
注意:本次样本中的 get_blob_args 实际读取的是 request_context_parts.mcps_blob_id,不是 conversation_state.turns[] 的 Turn Blob。样本没有直接证明服务端会在每个新 turn 中重新读取历史 Turn Blob;服务端可能已经保存或缓存了这些内容。
7.4 当前会话中的证据
第一个 turn:
- 服务端通过 RunSSE 发送 9 个
set_blob_args。 - 客户端通过 BidiAppend 返回 9 个
set_blob_result。 - 服务端随后发送
conversation_checkpoint_update。
第二个 turn:
run_request已携带上一轮conversation_state。- 服务端先读取 29,974 字节的
mcps_blob_id,客户端返回get_blob_result。 - 服务端再发送 7 个
set_blob_args,客户端逐一确认。 - 服务端发送新的 checkpoint,然后结束流。
后续第三个 turn:
- 服务端读取 59,145 字节的新
mcps_blob_id。 - 服务端发送 14 个
set_blob_args,客户端逐一确认。
这两次 get_blob_result 的返回数据都与请求的 blob_id 通过 SHA-256 校验一致。
该顺序说明 KV 同步不是与 conversation 无关的后台缓存。它直接参与 checkpoint 提交和 turn 收口:服务端先确保必要内容能够被客户端读取,再发布引用这些内容的状态清单。
7.5 KV 的架构作用
该设计提供以下能力:
- 缩小 checkpoint:主状态只携带引用,不必每轮重复传输完整历史。
- 内容去重:未变化的消息、step 或 turn 使用相同 SHA-256,只需保存一次。
- 幂等写入:相同
blob_id永远对应相同内容,重复set_blob不会产生语义冲突。 - 按需加载:服务端可以只读取当前恢复流程需要的 Blob;当前样本明确观察到的是 MCP 请求上下文按需读取。
- 跨 Worker 恢复:新的 Agent Worker 可以根据客户端携带的 checkpoint 和 Blob 恢复上下文,不必依赖原进程内存。
- 避免悬空引用:客户端确认 Blob 已保存后,服务端才发布最终 checkpoint。
- 客户端状态参与:本地客户端不仅执行工具,也充当 Agent 会话对象存储协议的一部分。
由此可以确认:
- checkpoint 元数据可以由客户端携带到下一轮。
- 较大的 checkpoint 内容使用内容寻址 Blob 拆分。
- 客户端至少承担 Blob 存取接口或本地 Blob 缓存的角色。
- 服务端会等待必要 Blob 写入得到确认,再完成 checkpoint 和 turn 收口。
KV 的本质因此不是“保存几个键值”,而是客户端侧的 Agent 会话对象存储协议。它与 checkpoint 一起构成“客户端携带状态 + 内容寻址 Blob 同步”的混合状态模型。
抓包不能证明服务端完全不保存这些数据,也不能证明其设计目的包含隐私或数据本地化;它只能证明客户端是状态协议中的实际参与者,而不是薄 UI。
8. 原服务的逻辑架构
flowchart LR
Client["Cursor Desktop<br/>UI / Local Tools / KV Blob"]
Gateway["API Gateway<br/>Auth / Route / Affinity"]
Actor["Request Actor<br/>request_id"]
Broker["Stream Broker<br/>Backlog / Subscribers"]
Conversation["Conversation State<br/>conversation_id"]
Provider["Model Provider"]
Client -->|"BidiAppend commands/results"| Gateway
Gateway --> Actor
Actor --> Provider
Provider --> Actor
Actor --> Broker
Broker -->|"RunSSE events"| Gateway
Gateway --> Client
Actor <--> Conversation
8.1 客户端:本地执行面
客户端负责:
- IDE 和 UI 交互。
- 本地文件、终端、编辑器及其他环境能力。
- 接收服务端的 Exec、KV 和 Interaction 请求。
- 执行本地操作并回传结果。
- 携带 conversation checkpoint,并参与 blob 存取。
- 维护上行
append_seqno和连接心跳。
8.2 云端:控制面与推理编排器
服务端负责:
- 接收
run_request并创建或恢复 turn。 - 编排模型 provider 调用。
- 将 provider 增量转换为
AgentServerMessage。 - 管理等待中的本地工具、KV 和用户交互请求。
- 根据外部结果恢复模型循环。
- 生成 checkpoint,并协调 blob 写入确认。
- 发布终态并结束 RunSSE。
因此原服务更接近 Agent workflow orchestrator,而不是一个简单的聊天补全 API。
8.3 Request Actor / Workflow
每个活动 request_id 很可能对应一个串行状态实例,可抽象为 Actor 或 workflow:
created
-> provider_running
-> waiting_external / awaiting_user
-> provider_running
-> checkpointing
-> completed / failed / canceled
BidiAppend 是该 Actor 的 command inbox,RunSSE 是该 Actor 的 event stream。这个结构具有明显的 CQRS 形态,但仅凭协议不能断言原服务使用了某个具体 Actor 或事件溯源框架。
9. 心跳与连接恢复
第一个 turn 中,客户端约每 5 秒通过 BidiAppend 发送一次 client_heartbeat。RunSSE 中也出现服务端 heartbeat。
双向心跳分别解决不同问题:
- 客户端 heartbeat 告诉服务端本地控制通道仍存活。
- 服务端 heartbeat 保持 RunSSE 活跃,并帮助客户端发现下行连接异常。
由于业务事件与 request_id、append_seqno 和 checkpoint 分离,协议具备处理短暂重连、请求重试和重复 append 的基础。不过,抓包中尚未出现实际断线重连样本,无法确认原服务的 backlog 保留时长和精确恢复策略。
10. 水平扩展约束
BidiAppend 与 RunSSE 是两个独立 HTTP 请求。在多副本部署中,它们可能被负载均衡器分配到不同实例,但必须访问同一个 request_id 状态。
因此原服务至少需要满足以下一种条件:
- API Gateway 按
request_id或会话信息执行粘性路由。 - 所有实例共享活动流存储、消息 Broker 或分布式 Actor runtime。
- RunSSE 实例只负责订阅共享事件流,实际 workflow 在独立 worker 中运行。
从协议上无法确定原服务具体采用哪一种。更可能的生产形态是“网关 + request workflow worker + 共享状态/事件基础设施”,但这仍属于部署推测。
11. 可以确认与不能确认的内容
11.1 可以直接确认
- 客户端使用 Connect-ES 1.6.1。
- 业务消息使用 protobuf。
- BidiAppend 是 unary 上行,RunSSE 是 server-streaming 下行。
- RunSSE 使用 Connect 二进制 envelope,而非标准文本 SSE。
- 同一 conversation 的不同 turn 使用不同
request_id。 - 上行消息通过
append_seqno排序。 - 客户端参与 KV/blob 存取和 checkpoint 延续。
- 每个成功样本最终都收到 EndStream 帧。
11.2 由协议必然产生的架构约束
- 服务端必须将两个独立 HTTP 通道汇合到同一个活动请求状态。
- 服务端必须处理并发、乱序、重复或重试的 BidiAppend。
- 服务端需要维护等待中的工具、KV 和 Interaction 关联状态。
- RunSSE 断开时,服务端必须决定取消、保留或允许恢复活动 run。
11.3 当前不能确认
- 原服务使用的编程语言和服务框架。
- 客户端到服务端实际使用 HTTP/1.1 还是 HTTP/2。
- 是否使用 Redis、Kafka、Temporal、Orleans、Akka 或其他具体基础设施。
- 是否依赖负载均衡粘性会话。
- 服务端是否也持久保存完整 checkpoint blob。
- RunSSE 重连时 backlog 的保留期限和恢复游标协议。
12. 总结
原 Agent 系统可以概括为一个分布式状态机:云端持有推理控制和 workflow,客户端持有 IDE 执行能力并参与会话状态存取。Connect RPC 提供传输封装,BidiAppend 和 RunSSE 共同模拟逻辑双向流,request_id 绑定一次活动运行,conversation_id 绑定跨 turn 的持久会话,checkpoint 与内容寻址 blob 负责状态延续。
这种设计的主要目的不是单纯流式输出文本,而是在浏览器兼容的 HTTP RPC 上承载可恢复、可排序、可调用本地工具的远程 Agent runtime。