mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
339 lines
12 KiB
Markdown
339 lines
12 KiB
Markdown
# 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 RPC,Blob 生命周期很可能由客户端本地存储策略、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 的关键协议层。
|