12 KiB
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_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. 内容寻址规则
当前样本明确验证出:
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 字节”这一表象。
内容寻址带来三个直接性质:
- 相同内容得到相同 ID,可以去重。
- 内容变化必然得到新 ID,Blob 可以视为不可变对象。
- 客户端和服务端都能通过重新计算哈希校验传输是否损坏。
空内容也有对应的内容地址。样本中的空 rules 和 subagents 使用 SHA-256 空串值:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
6. Blob 引用的两类主要用途
6.1 Conversation checkpoint
ConversationStateStructure 的多个 bytes 字段实际可以承载 Blob 引用,例如:
turns[]root_prompt_messages_json[]conversation_state_blob_idprompt_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 写入有两层确认:
- HTTP/Connect 层返回
BidiAppendResponse,表示上行 append 被接收。 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 RPC,Blob 生命周期很可能由客户端本地存储策略、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 协议中的状态同步层,承担三个角色:
- checkpoint 的内容存储:把历史消息、步骤和 turn 拆成不可变 Blob。
- 大型上下文传输:通过引用传递 Rules、Skills、Subagents 和 MCP 数据。
- 客户端能力桥接:云端通过 RunSSE 请求本地客户端保存或读取 Blob,再通过 BidiAppend 获得结果。
所以它不是普通的 KV 缓存,而是连接云端 Agent workflow、客户端本地状态和可恢复 conversation 的关键协议层。