Skip to content

🐣 小a的远程难题

pi 在本地跑得好好的。某天老z说:"工坊要开分店——让 Agent 跑在服务器上,客户端只管显示。"

小a问:"那服务器和客户端之间,怎么传消息?"

老z:"用网络呗。"

小a:"网络是字节流啊,我发一条消息,对面怎么知道这条消息从哪开始、到哪结束?万一被拆成两半,或者两条粘一起了怎么办?"

老z乐了:"问到点子上了。这就是 pi-protocol 要解决的——在不可靠的字节流上,传可靠的消息。"


21.1 病因:字节流没有"边界"

先诊断。TCP/Unix socket 这些传输层,都是字节流——一串连续的字节,没有"消息边界"。

🧙 字节流的灾难:

你发了三条消息:"你好""在吗""忙吗"。但在字节流里,它们可能变成:

情况①(粘包):"你好在吗忙吗"  ← 三条粘一起,对面分不开
情况②(半包):"你好在" + "吗忙吗" ← 一条被拆两半

TCP 不保证"发几条收几条",只保证"字节顺序对"。 消息边界,得应用层自己解决。


21.2 解法一:帧(Framing)—— 给消息加"长度前缀"

pi 的第一个解法:每条消息前面,加 4 个字节表示长度

📄 源码证据 packages/protocol/src/framing.ts

typescript
const FRAME_HEADER_LENGTH = 4;
const MAX_UINT32 = 0xffff_ffff;   // 4 字节能表示的最大长度

/** Prefixes a payload with its unsigned 32-bit big-endian byte length. */
export function encodeFrame(payload: Uint8Array): Uint8Array {
  if (!(payload instanceof Uint8Array)) {
    throw new TypeError("Frame payload must be a Uint8Array");
  }
  if (payload.byteLength > MAX_UINT32) {
    throw new RangeError("Frame payload exceeds the unsigned 32-bit length limit");
  }
  const frame = new Uint8Array(FRAME_HEADER_LENGTH + payload.byteLength);
  const length = payload.byteLength;
  frame[0] = length >>> 24;   // 最高位字节
  frame[1] = length >>> 16;
  frame[2] = length >>> 8;
  frame[3] = length;          // 最低位字节
  frame.set(payload, FRAME_HEADER_LENGTH);
  return frame;
}

🧙 这段代码干了什么?

一条消息(payload),前面加上 4 个字节的"长度"(big-endian 大端序)。接收方:

  1. 先读 4 字节,知道"接下来这条消息有多长"(比如 1000 字节)
  2. 再读 1000 字节,就是完整的一条消息

老z打比方:像快递包裹上贴的"重量标签"——先看标签知道这箱多重,再搬对应的重量。有了长度前缀,粘包半包都解决了:

  • 粘包:读完一条(按长度),剩下的属于下一条
  • 半包:长度不够就等,凑够了再处理

解码侧:把 4 字节还原成长度

📄 源码证据 framing.ts:88(FrameDecoder.push 里还原长度)

typescript
const length =
  header[0]! * 0x1_000_000 +   // 第 1 字节 × 2^24
  header[1]! * 0x1_0000 +      // 第 2 字节 × 2^16
  header[2]! * 0x100 +         // 第 3 字节 × 2^8
  header[3]!;                   // 第 4 字节 × 2^0

编码用移位(>>> 24),解码用乘法(* 0x1_000_000)——一正一反,同一套大端字节序。 两边对不上,数据就全乱了。

两个 throw:为什么"长度超限"和"类型不对"要抛错?

🧙 这是 fail loud(第 15 章)在协议层的体现:

  • payload 不是 Uint8Array → 直接抛 TypeError,不悄悄当空数据处理
  • payload 超过 4 字节上限(约 4GB) → 抛 RangeError,不截断静默

协议是最底层,错误在这里静默吞掉,上层根本无从察觉。 所以这里宁可抛错,也不将就。

大端序(big-endian)是什么

🧙 frame[0] = length >>> 24 这是在把长度拆成 4 个字节,最高位在前——这叫大端序

为什么用大端序?它是网络协议的惯例(网络字节序)。统一字节序,不同机器(大小端不同)才能互通。


21.3 解法二:CBOR —— 比 JSON 紧凑的二进制编码

光有帧不够——payload 里装的是什么格式?pi 用 CBOR(Concise Binary Object Representation)。

📄 源码证据 packages/protocol/src/cbor/(自实现,不依赖外部库)

cbor/
├── encoder.ts   ← 编码器
├── decoder.ts   ← 解码器
├── options.ts
└── index.ts

🧙 为什么用 CBOR 而非 JSON?

JSONCBOR
格式文本二进制
体积大(键名、引号、空格)紧凑
类型有限(没二进制、日期)丰富(支持二进制等)
可读性人能读人读不了

pi 选 CBOR,因为远程会话消息量大,二进制省带宽。 代价是不可读(调试难),但 pi 有补偿——严格的 schema 校验,出错时报清晰的错误。

CBOR 是什么(科普)

🧙 CBOR 是 RFC 8949 标准的二进制 JSON 替代品。它和 JSON 表达同样的数据结构(对象、数组、字符串、数字),但用二进制编码——更小、更快、类型更丰富。很多 IoT 和高性能协议用它。


21.4 schema 校验:收到消息先验证

pi-protocol 不只是"编码传输",还做schema 校验——收到消息先验证它合不合法。

📄 源码证据 packages/protocol/src/codec.ts:170

typescript
export function isSupportedProtocolVersion(version: number): version is typeof PROTOCOL_VERSION {
  return Number.isInteger(version) && version === PROTOCOL_VERSION;
}

还有 encodeClientMessage() / encodeServerMessage() —— 编码前先校验,不合法的消息直接拒绝。

🧙 为什么这么严格?

网络消息可能被篡改、可能来自错误版本的客户端。不校验就解析,容易出安全问题或奇怪 bug。 pi 的原则:schema 违反、CBOR 损坏、帧格式错,都抛 ProtocolValidationError,绝不静默吞掉。(fail loud,呼应附录 E)


21.5 最深的洞见:snapshot vs progress

这是整个协议最精妙的设计,老z重点讲。

pi 把服务器发的消息分成两类:

📄 源码证据 packages/protocol/src/schemas.ts:398-403

typescript
// ① snapshot —— 权威快照
StrictObject({ type: Type.Literal("server_snapshot"), snapshot: ServerSnapshotSchema }),
StrictObject({ type: Type.Literal("session_snapshot"), snapshot: SessionSnapshotSchema }),

// ② progress —— 瞬态进度
StrictObject({
  type: Type.Literal("session_progress"),
  progress: TranscriptProgressSchema,
}),

🧙 两类消息的本质区别:

snapshot(快照)progress(进度)
性质权威的完整状态瞬时的增量提示
丢了怎样灾难(状态不完整)无所谓(只是 UI 提示)
客户端怎么用必须据此重建状态只用来刷新显示,不存
注释Snapshots remain authoritativeNormalized incremental activity

关键认知(源码注释原话): "Snapshots remain authoritative"——快照是唯一真相源。progress 只是"顺便告诉你一下进展",客户端不能把 progress 累积成状态,因为 progress 可能丢、可能乱序。

为什么这么设计?

🐣 小a问:"为什么不直接发增量(progress),客户端自己累加?"

🧙 "因为累加会出错。 想象:

  • progress 消息丢了 → 客户端状态缺失
  • progress 乱序到达 → 客户端状态错乱
  • 客户端崩了重启 → 它的『累加状态』没了

pi 的解法:服务器定期发完整 snapshot(权威),progress 只是锦上添花。 客户端以 snapshot 为准,progress 丢了不影响正确性,只影响『显示多及时』。"

这是分布式系统的经典权衡:权威状态 vs 增量更新。 pi 选了"权威优先",牺牲一点实时性,换取状态的绝对正确


21.6 完整的消息流

把所有零件串起来,一次完整的通信:

客户端                            服务器
  │                                 │
  │ ① hello(协议版本 + token)      │
  │ ────encodeClientMessage────→    │
  │   (schema 校验 → CBOR → 帧)    │
  │                                 │
  │                    ② 校验版本/token │
  │                       发 session_snapshot│
  │ ←───encodeServerMessage────    │
  │   (帧 → CBOR → schema 校验)    │
  │                                 │
  │ ③ 客户端重建状态(以 snapshot为准)│
  │                                 │
  │         ④ 服务器发 progress     │
  │ ←──(瞬态,只刷新显示)──────    │
  │                                 │
  │ ⑤ 客户端请求(帧+CBOR)────→    │
  │                                 │
  │       ⑥ 服务器处理,发新 snapshot│
  │ ←──────────────────────────    │
  └─────────────────────────────────┘

🧙 关键:每条消息都经过"schema 校验 → CBOR 编码 → 帧封装",反之亦然。 三道工序保证消息合法、紧凑、有边界。


21.7 代价

🧙 代价一:CBOR 不可读

抓包看到的是二进制,调试得用专门工具解码。不像 JSON 直接能读。

代价二:协议版本管理

客户端和服务器版本得匹配(PROTOCOL_VERSION)。版本不一致直接拒绝连接——牺牲兼容性换严格性

代价三:复杂度

自实现 CBOR + 帧解码 + schema——这套东西不简单。但对远程会话这种核心场景,值得。


本章小结

🐣 小a的第二十一课

┌─────── 字节流上的可靠消息 ───────┐
│                                 │
│  • 病因:字节流无边界(粘包/半包)│
│                                 │
│  • 解法一:帧(Framing)         │
│    4 字节大端长度前缀            │
│    → 解决边界问题                │
│                                 │
│  • 解法二:CBOR                  │
│    二进制编码,比 JSON 紧凑      │
│    → 省带宽(代价:不可读)      │
│                                 │
│  • schema 校验:编码前先验证     │
│    → 不合法直接拒绝(fail loud) │
│                                 │
│  • 最深洞见:snapshot vs progress│
│    snapshot = 权威,必须重建     │
│    progress = 瞬态,只刷新显示   │
│    → 权威优先,保证状态正确      │
└─────────────────────────────────┘

关键认知:pi-protocol 用"帧+CBOR+schema"在不可靠字节流上建可靠通信,用"snapshot 权威 vs progress 瞬态"保证状态正确。这是分布式 Agent 通信的工程范本。


课后实验

  1. 读 encodeFrame:打开 framing.ts,读懂 encodeFrame 怎么把长度拆成 4 字节。试着写个 decodeFrame(读 4 字节长度,再读对应字节)。
  2. 理解大端序:查资料搞懂"大端序 vs 小端序",理解为什么网络协议用大端。
  3. 体会 snapshot/progress:想一个场景——如果只有 progress 没有 snapshot,客户端崩溃重启后会怎样?(答案:状态丢失,因为 progress 不能重建状态)

下一章:协议有了,谁来实现服务器和客户端?看 server 和 client 包。 → 第 22 章 · server/client 源码