Skip to content

3.3 消息 Schema

协议不能是"随便传对象"。pi 用 typebox Schema 严格定义协议里每一种消息的形状,编解码时据此校验。这一节梳理协议的消息全景

先整明白:服务端、客户端、快照分别是什么

服务端(server)与客户端(client)是两个进程的分工

  • 服务端:真正持有并运行 Agent 会话的一方(跑 Agent 循环、执行工具、管理会话)。
  • 客户端:只是连接服务端、展示/操作会话的一方(比如 TUI 界面)。它自己不跑 Agent。
┌─────────────┐   (请求/响应)   ┌──────────────────┐
│  客户端       │ ──────────────► │  服务端            │
│  (TUI/UI 端)  │ ◄────────────── │  (持有 Agent 会话)  │
└─────────────┘   (事件推送)     └──────────────────┘

快照(Snapshot):服务端不能每毫秒把整个会话发给客户端(太慢)。它用"快照 + 增量":

快照 Snapshot = 某一刻会话的完整状态(一条命令返回时给你一份"全貌")
增量 Progress = 之后的变化(模型吐了句、工具执行了,只告诉你"变了啥")

类比:快照是一张照片(这一瞬间的完整画面),增量是后续的几帧变化。客户端拿到照片就有全貌,之后靠增量小步更新,不用每次重拍。

整体流程:两端在做什么

3.3 的 Schema 就是在定义"两端之间传的消息长什么样"。整体交互:

客户端                          服务端
  │  ① hello(握手,带版本)───► │
  │                             │ 校验版本
  │ ◄── ② hello_ok + 初始快照 ── │ 发给客户端一份"全貌"
  │                             │
  │  ③ 请求(prompt/steer/...)► │ 执行 Agent 会话
  │ ◄── ④ 事件推送(增量)────── │ 边跑边推变化
  │ ◄── ⑤ 响应(带最新快照)──── │ 命令完成,附最新"全貌"

对应到消息类型:

方向消息何时
客户端→服务端ClientHello① 连接时握手
客户端→服务端RequestEnvelope③ 发命令
服务端→客户端ServerHello / ServerHelloError② 握手结果
服务端→客户端ResponseEnvelope⑤ 命令响应
服务端→客户端EventEnvelope④ 主动推事件

协议的两端:客户端 → 服务端,服务端 → 客户端

客户端                          服务端
  │ ClientHello ──────────────► │
  │ RequestEnvelope ──────────► │
  │                              │ ServerHello(首帧回)
  │ ◄───────────────────────── ServerHello / ServerHelloError
  │ ◄───────────────────────── ResponseEnvelope(响应)
  │ ◄───────────────────────── EventEnvelope(事件推送)

客户端消息

ClientMessage = ClientHello | RequestEnvelopeschemas.ts:392)。

ClientHello(握手首帧)

ts
{ type: "hello", version: number }   // 必须是整数,见 codec.isSupportedProtocolVersion

RequestEnvelope(请求封装)

ts
{ type: "request", id: string, request: Command }

id 用于把响应与请求对应起来。

命令(Command)

请求体是 Command 联合类型(schemas.ts:309),覆盖一个远程 Agent 会话的全部操作:

命令作用
list列出所有会话
create新建会话(可指定 cwd / model / thinkingLevel)
attach / detach挂载 / 卸载会话
prompt发起一次提问
steer中途插话
abort中止当前运行
set_model切换模型
set_thinking切换思考档位
ts
// 例如 prompt 命令
{ command: "prompt", sessionId: string, text: string }

服务端消息

ServerMessageschemas.ts:435)包含:

类型含义
ServerHello握手成功,携带 connectionId 和初始 ServerSnapshot
ServerHelloError握手失败(版本不匹配等)
ResponseEnvelope对某个请求的响应(ok: true 带 result / ok: false 带 error)
EventEnvelope主动推送的事件(快照更新、增量进度)

快照(Snapshot):权威状态

服务端不发送"完整转录的每次增量",而是发送快照 + 增量

ts
// 服务端整体快照
ServerSnapshot = { serverId, protocolVersion, revision, sessions: SessionSummary[], models: ModelMetadata[] }

// 单个会话快照
SessionSnapshot = { ...summary, revision, transcript: TranscriptItem[], queuedSteer: [] }

// 增量进度
SessionProgress = { sessionId, progress: item_started | assistant_delta | item_updated | item_finished }

关键设计:快照是权威的,增量是性能优化。客户端收到快照就重建全量视图;收到增量就做局部更新(见 4.2)。

用一个时间线感受"快照 + 增量"怎么配合:

text
时刻  客户端本地视图                         服务端发生了什么
────────────────────────────────────────────────────────────────────
 t1   空(啥都没有)
        │◄── 快照(完整转录)──────────────── 会话开始,给全貌
 t2   [user, assistant, tool]  ← 完整视图
        │◄── 增量(assistant_delta)───────── 模型吐了个字
 t3   [user, assistant+"字", tool]          (只更新了那一句)
        │◄── 增量(item_finished)─────────── 工具跑完了
 t4   [user, assistant, tool(完成)]
        │◄── 快照(完整,兜底)──────────── 命令完成,再给一次全貌
────────────────────────────────────────────────────────────────────

快照少而全(保证一致),增量大而碎(保证实时)—— 两者配合,既省带宽又不丢状态。

转录条目(TranscriptItem)

快照里的 transcriptTranscriptItem 数组,即一条条会话记录:

ts
TranscriptItem =
	| UserTranscriptItem                               // user
	| AssistantTranscriptItem                          // assistant(streaming/complete/error/aborted)
	| ToolTranscriptItem                               // tool(running/complete/error)

每个条目都有 idroletimestamp,助手条目带 modelusage,工具条目带 toolCallIdtoolNameinputcontent

注意协议里工具角色叫 "tool"(而 @pi/ai 里叫 "toolResult")—— 协议有自己的一套词汇,适配层负责转换。

Schema 的两大作用

  1. 生成 JSON Schema:typebox 的 Type.* 可以编译成标准 JSON Schema,用于校验。
  2. 运行时校验Check(Schema, value)(来自 typebox/value)在编解码时执行。

StrictObject 还强制 additionalProperties: false拒绝多余字段,防止协议漂移。

小结

  • 协议 = ClientHello + 请求/响应封装 + 事件封装。
  • 命令列表 = 一个远程 Agent 会话的全部操作。
  • 服务端发快照(权威)+ 增量(优化)
  • 转录条目描述一条会话记录;typebox 保证形状严格。
真实源码位置
  • 全部 Schema:packages/protocol/src/schemas.ts
  • 版本常量:schemas.ts:3

面试角度:为什么这样订 Schema

Q1:为什么用 StrictObject 并强制 additionalProperties: false 因为协议要严格:多余的字段可能是拼写错误、版本漂移或攻击。拒绝多余字段能及早暴露"两端对协议理解不一致",避免静默吞掉未知字段导致行为差异。这是协议稳定性的重要保障。

Q2:为什么服务端推"快照 + 增量"而不是每次全量推完整转录? 因为全量转录会非常庞大(一次长会话可能上万条消息)。策略是:快照是权威(客户端收到就重建全量视图),增量是性能优化session_progress 只推变化,客户端局部打补丁)。这样既保证一致性(快照兜底),又省带宽(增量为主)。

Q3:为什么状态(转录)用 TranscriptItem 统一建模,而不是直接复用 @pi/aiMessage 因为协议有自己的词汇(工具角色叫 tool,而内核叫 toolResult;还带 status 表示 streaming/complete/error 等)。协议层需要独立、稳定、跨语言的消息模型,不能和某个具体实现(@pi/ai)耦合。适配层负责转换。

Q4:为什么命令和结果都要有严格 Schema? 因为协议是"跨进程的契约"。命令(Command)和结果(CommandResult)严格定义,客户端才知道能发什么、服务端才知道会回什么。配上 ResultForCommand 的映射,还能在类型层面保证"某种命令对应某种结果"。

下一步进第四部分:4.1 服务端与会话管理