Files
cursor-byok/docs/Bidi-RunSSE原服务架构推断.md
T
2026-08-13 22:01:11 +08:00

436 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BidiAppend / RunSSE 原服务架构推断
本文根据实际抓包、已提取的 protobuf 定义以及同一 `conversation_id` 下的消息关联关系,分析 Cursor Agent 原服务采用的通信技术、运行方式和可能的服务架构。
本文只描述协议事实和架构推断,不描述当前项目的实现方案。
## 1. 分析样本
本次分析使用以下会话:
```text
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
抓包中的请求头包含:
```text
Connect-Protocol-Version: 1
User-Agent: connect-es/1.6.1
```
这说明桌面客户端的协议调用层使用 Connect-ES。它很可能运行在 Cursor 的 Electron / VS Code JavaScript 环境中。
`BidiAppend` 使用:
```text
Content-Type: application/proto
```
这是一个 protobuf unary RPC。每次请求只追加一条 `AgentClientMessage`,服务端返回空的 `BidiAppendResponse` 作为接收确认。
`RunSSE` 请求使用:
```text
Content-Type: application/connect+proto
Connect-Accept-Encoding: gzip
Connect-Content-Encoding: gzip
```
响应使用:
```text
Content-Type: text/event-stream
Connect-Content-Encoding: gzip
```
`text/event-stream` 在这里是兼容性响应类型,实际正文仍使用 Connect 二进制 envelope。因此不能使用标准 EventSource 文本解析器处理该响应。
### 3.2 Connect 流式帧
每个 RunSSE 消息使用以下帧结构:
```text
+------------+----------------------+--------------------+
| 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 之前准备好下行订阅,避免遗漏早期事件。
```mermaid
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
```
完整生命周期为:
1. 客户端生成本次 turn 的 `request_id`
2. 客户端使用 `BidiRequestId` 建立 RunSSE 下行流。
3. 客户端通过 BidiAppend 发送 `run_request`
4. 服务端启动模型调用并持续发送 `thinking_delta``text_delta``token_delta` 和 step 状态。
5. 服务端需要客户端能力时,通过 RunSSE 发送 KV、Exec 或 Interaction 请求。
6. 客户端执行本地操作,并通过 BidiAppend 返回对应结果。
7. 服务端根据外部结果继续模型循环,或者进入 turn 收口阶段。
8. 服务端同步 checkpoint 及其 blob。
9. 服务端发送 EndStream,结束本次 `request_id` 对应的流。
## 5. 上行顺序与幂等语义
`BidiAppendRequest.append_seqno` 是同一个 `request_id` 内的有序序号。
第一个 turn 中观察到:
```text
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-Id` HTTP 请求头中。
- 本次 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 StoreContent-Addressable StoreCAS),用于保存和恢复 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:
```text
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 本身更接近一个引用清单。会话历史可以形成如下内容寻址对象图:
```text
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 写入流程为:
1. 服务端将用户消息、conversation step 和 turn 等节点分别序列化。
2. 服务端对每个序列化结果计算 SHA-256,得到 `blob_id`
3. 服务端通过 RunSSE 发送 `set_blob_args`
4. 客户端保存 Blob,并通过 BidiAppend 返回 `set_blob_result`
5. 必要 Blob 全部确认后,服务端发送引用这些 Blob 的 `conversation_checkpoint_update`
6. 服务端完成本次 turn 并发送 EndStream。
下一轮恢复状态时,Blob 读取流程通常为:
1. 客户端将上一轮 checkpoint 随 `run_request` 发回。
2. 服务端读取 checkpoint 和 `request_context_parts` 中的 Blob 引用。
3. 服务端按需通过 RunSSE 发送 `get_blob_args` 请求自己当前缺少的内容。
4. 客户端通过 BidiAppend 返回 `get_blob_result`
5. 服务端使用已持有或刚读取的 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. 原服务的逻辑架构
```mermaid
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
```text
created
-> provider_running
-> waiting_external / awaiting_user
-> provider_running
-> checkpointing
-> completed / failed / canceled
```
BidiAppend 是该 Actor 的 command inboxRunSSE 是该 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` 状态。
因此原服务至少需要满足以下一种条件:
1. API Gateway 按 `request_id` 或会话信息执行粘性路由。
2. 所有实例共享活动流存储、消息 Broker 或分布式 Actor runtime。
3. 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。