Files
cursor-byok/docs/KV协议详细分析.md
T
2026-08-13 22:01:11 +08:00

12 KiB
Raw Blame History

KV 协议详细分析

本文专门分析 Agent 协议中的 KvServerMessage / KvClientMessage。分析依据包括当前 agent_v1.proto、Connect 帧结构和本地 SQLite 抓包。

本文中的 KV 不指普通业务配置表,而指服务端通过 RunSSE 调用客户端 Blob Store 的协议。

1. 一句话结论

KV 是一个客户端参与的内容寻址 Blob RPC

  • 服务端请求客户端按 blob_id 保存或读取二进制内容。
  • blob_id 是 Blob 内容的稳定地址,而不是随机数据库主键。
  • Blob 主要用于 conversation checkpoint、prompt context 和其他大型上下文。
  • KV 操作发生在 Agent 流内部,不是独立的 HTTP KV 服务。

更准确的技术名称是:

Client-side content-addressed Blob Store over an application-level reverse RPC.

2. 协议分层

KV 不是直接出现在 HTTP body 顶层,而是嵌套在两条 Connect RPC 中。

2.1 服务端到客户端

Connect server stream
  -> AgentServerMessage
      -> KvServerMessage
          -> GetBlobArgs / SetBlobArgs

对应的 protobuf

message KvServerMessage {
  uint32 id = 1;
  optional SpanContext span_context = 4;
  oneof message {
    GetBlobArgs get_blob_args = 2;
    SetBlobArgs set_blob_args = 3;
  }
}

2.2 客户端到服务端

Connect unary BidiAppend
  -> BidiAppendRequest
      -> data: hex(AgentClientMessage)
          -> KvClientMessage
              -> GetBlobResult / SetBlobResult

对应的 protobuf

message KvClientMessage {
  uint32 id = 1;
  oneof message {
    GetBlobResult get_blob_result = 2;
    SetBlobResult set_blob_result = 3;
  }
}

因此,KV 的“请求方向”是 RunSSE,下行;KV 的“响应方向”是 BidiAppend,上行。这是应用层反向 RPC,不是客户端直接向某个 /kv HTTP endpoint 发请求。

3. 消息和参数

3.1 GetBlobArgs

message GetBlobArgs {
  bytes blob_id = 1;
}

功能:要求客户端返回指定 Blob。

blob_id 是二进制字段。当前抓包中长度为 32 字节,显示为 Base64 时通常是 44 个字符。

3.2 GetBlobResult

message GetBlobResult {
  optional bytes blob_data = 1;
  optional Error error = 2;
}

成功时返回 blob_data;读取失败时返回 error.message。协议没有单独定义 not_found 枚举,缺失、损坏和存储错误都需要通过 Error 文本表达。

3.3 SetBlobArgs

message SetBlobArgs {
  bytes blob_id = 1;
  bytes blob_data = 2;
}

功能:要求客户端按指定地址保存一段完整的 Blob。

KV 本身没有分片字段。一个 Blob 必须在一条 SetBlobArgs 中完整传输;大型内容依靠 Connect 的压缩和多个 Blob 拆分,而不是依靠 KV 内部的 chunk 序号。

3.4 SetBlobResult

message SetBlobResult {
  optional Error error = 1;
}

没有 error 表示写入成功;有 error 表示客户端拒绝或无法保存。

3.5 SpanContext

KvServerMessage.span_context 可携带 trace_idspan_idtrace_flagstrace_state。它用于分布式追踪,不参与 Blob 寻址、版本控制或响应关联。

4. 三个 ID 的区别

KV 运行时同时存在三种容易混淆的 ID:

ID 所属 作用 生命周期
request_id Bidi / RunSSE 绑定一条 Agent 活动流 一次 turn 或运行实例
KvServerMessage.id KV 操作 关联服务端操作和客户端结果 当前 request_id 内的一次操作
blob_id Blob 内容 内容寻址和引用 只要内容或 checkpoint 仍可达就有效

此外还有 BidiAppendRequest.append_seqno

  • KvServerMessage.id 解决“哪个 KV 响应对应哪个 KV 请求”。
  • append_seqno 解决“所有客户端上行消息应按什么顺序处理”。
  • 两者不能互相替代。

本地样本中每个新的 request_id 都将 KV 操作 ID 从 0 重新开始,而 Bidi 上行序号还会被 heartbeat、Exec 和其他客户端消息占用。

5. 内容寻址规则

当前样本明确验证出:

blob_id = SHA-256(blob_data)

验证结果:

检查项 结果
三个 turn 中观察到的 set_blob_args 30
blob_id == SHA-256(blob_data) 30 / 30
已观察的 get_blob_result 2
读取结果通过请求 ID 的 SHA-256 校验 2 / 2

协议字段本身没有声明哈希算法或版本字段,因此 SHA-256 是根据实际数据推断出来的协议约定。实现时仍应把算法视为可配置或保留版本扩展空间,而不应只依赖“32 字节”这一表象。

内容寻址带来三个直接性质:

  1. 相同内容得到相同 ID,可以去重。
  2. 内容变化必然得到新 ID,Blob 可以视为不可变对象。
  3. 客户端和服务端都能通过重新计算哈希校验传输是否损坏。

空内容也有对应的内容地址。样本中的空 rules 和 subagents 使用 SHA-256 空串值:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

6. Blob 引用的两类主要用途

6.1 Conversation checkpoint

ConversationStateStructure 的多个 bytes 字段实际可以承载 Blob 引用,例如:

  • turns[]
  • root_prompt_messages_json[]
  • conversation_state_blob_id
  • prompt_context_usage_snapshot_blob_id

在当前样本中,Turn Blob 可以解码为 ConversationTurnStructure,其结构为:

ConversationTurnStructure
  └─ AgentConversationTurnStructure
       ├─ user_message: blob_id
       ├─ steps[]: blob_id
       └─ request_id

UserMessage Blob 可以解码为 UserMessage,其中又包含 conversation_state_blob_id。这个状态 Blob 继续引用根 Prompt Blob 和其他 checkpoint 数据。

因此 checkpoint 不是一个扁平 JSON,而是一个由多个 protobuf Blob 组成的引用图。

6.2 Request context

ConversationAction.request_context_parts 使用专门的引用结构:

message RequestContextPartReferences {
  bytes rules_blob_id = 1;
  uint32 rules_byte_length = 2;
  bytes skills_blob_id = 3;
  uint32 skills_byte_length = 4;
  bytes subagents_blob_id = 5;
  uint32 subagents_byte_length = 6;
  bytes mcps_blob_id = 7;
  uint32 mcps_byte_length = 8;
  RequestContext dynamic_context = 9;
}

这些 Blob 用来传输较大的 rules、skills、subagents 和 MCP 定义;小型、动态字段继续放在 dynamic_context 内。

样本中第二、第三个 turn 的 get_blob_args 分别读取:

Turn 引用类型 Blob 大小
2 request_context_parts.mcps_blob_id 29,974 字节
3 request_context_parts.mcps_blob_id 59,145 字节

因此,KV 不只服务于 conversation history,也服务于每轮模型调用需要的大型上下文。

7. 当前会话的真实时序

7.1 第一个 turn

  • 发送 9 个 set_blob_args
  • 客户端返回 9 个 set_blob_result
  • 其中包括 UserMessage、ConversationStep、ConversationTurn 和 Prompt/State 相关 Blob。
  • 最终 checkpoint 的 turns[] 引用本轮的 Turn Blob。

7.2 第二个 turn

  • run_request 携带上一轮 conversation state 和新的 request context 引用。
  • 服务端读取 1 个 MCP context Blob,返回数据 29,974 字节。
  • 服务端发送 7 个新 Blob,包括本轮消息、步骤、turn 和新的 context 状态。
  • 服务端发布新的 checkpoint。

7.3 第三个 turn

  • 服务端读取新的 MCP context Blob,返回数据 59,145 字节。
  • 服务端发送 14 个新 Blob。
  • 该 turn 还出现了 Exec 请求和结果,说明 KV 与本地工具协议可以在同一个 request actor 中并行存在。

一个重要结论是:当前样本中的 get_blob 不应简单解释为“服务端从客户端读取上一轮对话历史”。实际观察到的 get_blob 是 MCP request context。历史 Turn Blob 可能由服务端缓存,也可能在其他未捕获的路径同步;本样本不足以证明其读取路径。

8. 并发、顺序和幂等

8.1 多个 KV 请求可以并发

服务端可以在一条 RunSSE 中连续发送多个 set_blob_args。客户端随后并发发起多个 BidiAppend。

当前样本中,KV 操作 ID 和 HTTP 到达顺序不一致。例如一个 turn 中操作 ID 3、4、5 的响应在抓包记录里并非严格按 3、4、5 排列。这说明服务端不能按 HTTP 请求到达顺序匹配 KV 结果,必须按 KvClientMessage.id 关联。

8.2 append_seqno 是全局上行顺序

KV 结果的 BidiAppend 还会与 heartbeat、Exec 结果共享同一个 append_seqno 序列。因此:

  • KV 操作 ID 只在 KV 子协议中使用。
  • append 序号覆盖所有 AgentClientMessage
  • 服务端需要先按 append 序号处理上行消息,再按 KV ID 将结果交给对应的等待状态。

8.3 写入幂等和 ACK

一次成功的 KV 写入有两层确认:

  1. HTTP/Connect 层返回 BidiAppendResponse,表示上行 append 被接收。
  2. KvClientMessage.set_blob_result 没有错误,表示客户端 Blob Store 确实完成写入。

只有第二层确认才代表 Blob 可被后续 checkpoint 引用。重复发送同一个 blob_id 不会改变内容,但服务端仍需要处理重复的操作 ID、过期结果和客户端重试。

9. 失败语义和边界

KV 协议没有独立的错误枚举、删除、列举、TTL 或批量操作。当前可表达的失败主要是:

  • get_blob_result.error:客户端找不到或无法读取 Blob。
  • set_blob_result.error:客户端无法保存 Blob。
  • BidiAppend 本身失败:上行 append 未被服务端接受。
  • RunSSE 断开或 EndStream 失败:下行 KV 请求可能尚未完成。

因此服务端需要维护 pending KV 操作表:

(request_id, KvServerMessage.id)
        -> blob_id
        -> waiting checkpoint / turn completion

当必要 Blob 写入失败或超时,服务端不能发布引用该 Blob 的成功 checkpoint;应选择重试、降级为未完成状态或结束当前 turn。

10. 安全与存储含义

KV 内容通过 HTTPS/Connect 传输,但协议本身没有声明 Blob 的存储加密、租户命名空间或访问权限。生产实现至少应考虑:

  • 按用户、workspace 或 conversation 做访问隔离,不能只依赖公开的 SHA-256 值。
  • blob_data 做大小限制和哈希校验。
  • 不把 Blob 正文写入普通请求日志。
  • 对未知或过期 KvClientMessage.id 做幂等处理。
  • 防止通过任意 get_blob 探测其他会话的内容。
  • 明确客户端 Blob 的持久化、清理和迁移策略。

由于协议没有 delete 或 garbage-collection RPCBlob 生命周期很可能由客户端本地存储策略、checkpoint 可达性或服务端外部存储策略负责。具体实现无法从当前抓包确认。

11. 对重写服务的直接启示

KV 不应被建模成一个简单的 map[string][]byte API。更合适的抽象是:

BlobStore
  Put(content) -> content_hash
  Get(content_hash) -> content
  Has(content_hash) -> bool

上层再增加一次 request-scoped 的 RPC 编排:

BlobOperation
  operation_id
  request_id
  blob_id
  kind: get | set
  status: pending | succeeded | failed | timed_out

Checkpoint 只保存 Blob 引用和小型元数据;Blob 本体由可替换的客户端存储适配器或共享存储适配器负责。KV 操作完成后,必须通过明确的 barrier 通知 checkpoint/turn 状态机继续收口。

12. 最终结论

KV 是 Agent 协议中的状态同步层,承担三个角色:

  1. checkpoint 的内容存储:把历史消息、步骤和 turn 拆成不可变 Blob。
  2. 大型上下文传输:通过引用传递 Rules、Skills、Subagents 和 MCP 数据。
  3. 客户端能力桥接:云端通过 RunSSE 请求本地客户端保存或读取 Blob,再通过 BidiAppend 获得结果。

所以它不是普通的 KV 缓存,而是连接云端 Agent workflow、客户端本地状态和可恢复 conversation 的关键协议层。