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

339 lines
12 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.
# 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 服务端到客户端
```text
Connect server stream
-> AgentServerMessage
-> KvServerMessage
-> GetBlobArgs / SetBlobArgs
```
对应的 protobuf
```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 客户端到服务端
```text
Connect unary BidiAppend
-> BidiAppendRequest
-> data: hex(AgentClientMessage)
-> KvClientMessage
-> GetBlobResult / SetBlobResult
```
对应的 protobuf
```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`
```protobuf
message GetBlobArgs {
bytes blob_id = 1;
}
```
功能:要求客户端返回指定 Blob。
`blob_id` 是二进制字段。当前抓包中长度为 32 字节,显示为 Base64 时通常是 44 个字符。
### 3.2 `GetBlobResult`
```protobuf
message GetBlobResult {
optional bytes blob_data = 1;
optional Error error = 2;
}
```
成功时返回 `blob_data`;读取失败时返回 `error.message`。协议没有单独定义 `not_found` 枚举,缺失、损坏和存储错误都需要通过 Error 文本表达。
### 3.3 `SetBlobArgs`
```protobuf
message SetBlobArgs {
bytes blob_id = 1;
bytes blob_data = 2;
}
```
功能:要求客户端按指定地址保存一段完整的 Blob。
KV 本身没有分片字段。一个 Blob 必须在一条 `SetBlobArgs` 中完整传输;大型内容依靠 Connect 的压缩和多个 Blob 拆分,而不是依靠 KV 内部的 chunk 序号。
### 3.4 `SetBlobResult`
```protobuf
message SetBlobResult {
optional Error error = 1;
}
```
没有 `error` 表示写入成功;有 `error` 表示客户端拒绝或无法保存。
### 3.5 `SpanContext`
`KvServerMessage.span_context` 可携带 `trace_id``span_id``trace_flags``trace_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. 内容寻址规则
当前样本明确验证出:
```text
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 空串值:
```text
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`,其结构为:
```text
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` 使用专门的引用结构:
```protobuf
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 操作表:
```text
(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。更合适的抽象是:
```text
BlobStore
Put(content) -> content_hash
Get(content_hash) -> content
Has(content_hash) -> bool
```
上层再增加一次 request-scoped 的 RPC 编排:
```text
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 的关键协议层。