Appearance
🐣 小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.tstypescriptconst 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 大端序)。接收方:
- 先读 4 字节,知道"接下来这条消息有多长"(比如 1000 字节)
- 再读 1000 字节,就是完整的一条消息
老z打比方:像快递包裹上贴的"重量标签"——先看标签知道这箱多重,再搬对应的重量。有了长度前缀,粘包半包都解决了:
- 粘包:读完一条(按长度),剩下的属于下一条
- 半包:长度不够就等,凑够了再处理
解码侧:把 4 字节还原成长度
📄 源码证据
framing.ts:88(FrameDecoder.push 里还原长度)typescriptconst 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?
JSON CBOR 格式 文本 二进制 体积 大(键名、引号、空格) 紧凑 类型 有限(没二进制、日期) 丰富(支持二进制等) 可读性 人能读 人读不了 pi 选 CBOR,因为远程会话消息量大,二进制省带宽。 代价是不可读(调试难),但 pi 有补偿——严格的 schema 校验,出错时报清晰的错误。
CBOR 是什么(科普)
🧙 CBOR 是 RFC 8949 标准的二进制 JSON 替代品。它和 JSON 表达同样的数据结构(对象、数组、字符串、数字),但用二进制编码——更小、更快、类型更丰富。很多 IoT 和高性能协议用它。
21.4 schema 校验:收到消息先验证
pi-protocol 不只是"编码传输",还做schema 校验——收到消息先验证它合不合法。
📄 源码证据
packages/protocol/src/codec.ts:170typescriptexport 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-403typescript// ① 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 通信的工程范本。
课后实验
- 读 encodeFrame:打开
framing.ts,读懂encodeFrame怎么把长度拆成 4 字节。试着写个decodeFrame(读 4 字节长度,再读对应字节)。 - 理解大端序:查资料搞懂"大端序 vs 小端序",理解为什么网络协议用大端。
- 体会 snapshot/progress:想一个场景——如果只有 progress 没有 snapshot,客户端崩溃重启后会怎样?(答案:状态丢失,因为 progress 不能重建状态)
下一章:协议有了,谁来实现服务器和客户端?看 server 和 client 包。 → 第 22 章 · server/client 源码