第一章我们看到,harness 的运行时是“消息驱动”的:前端与内核之间只流动 Op 和 Event。 但这只是故事的一半。内核的另一侧还面对着一条同样繁忙的消息河流——模型的流式响应。 本章拆开这两条协议:一条面向前端(人或 IDE),一条面向模型(推理服务)。 内核夹在中间,扮演协议网关的角色:把前端的命令翻译成模型请求,把模型的字节流翻译成前端能渲染的事件。
2.1 为什么 harness 需要“两条协议”
普通程序调用一个函数,签名就是它的协议:入参什么类型、返回什么值,编译器帮你保证一致。
Agent harness 没有这种奢侈。它的两侧都是异构、遥远、各自演化的对手:
- 内侧是模型服务。一个 HTTP(或 WebSocket)端点,请求体里塞上下文和工具清单,回复不是一个 JSON 对象,而是一条可能持续几十秒的事件流——文本一个字一个字地蹦,工具调用的参数逐片段成形,最后才告诉你“我说完了”。中途还可能限流、断连、被服务端安全审查拦下。
- 外侧是前端程序。可能是终端 TUI、IDE 插件、CLI、app-server 背后的多个网络客户端。它们有的和内核在同一进程里(内存通道),有的隔着进程边界(标准输入输出),有的隔着网络(WebSocket)。它们既要发号施令(“中断!”“批准!”),又要实时渲染(打字机效果、进度条、审批弹窗)。
两侧的协议风格截然不同:
| 前端协议(Op / Event) | 模型协议(Responses 流) | |
|---|---|---|
| 方向 | 双向:前端发命令,内核发事件 | 内核发请求,模型推流 |
| 形态 | 离散消息,几十上百种语义类型 | 一次请求 → 一条有序事件流 |
| 时效 | 命令可能排队、被拒绝、被应答 | 流只读一次,断了就重来 |
| 错误 | 是一种事件(Error / Warning) | 是流上的一个错误项(Err) |
| 取消 | 显式命令(Interrupt Op) | 没有取消消息——直接断开流 |
理解这张表,就理解了本章的所有设计:协议的形状,是由对话双方的关系决定的。 前端是同事,需要协商、需要确认、需要被征求意见;模型是一个“启动后只负责输出”的推理引擎,关系简单粗暴。
graph LR
subgraph 外侧["外侧:前端世界"]
TUI["TUI"]
IDE["IDE 插件 / 网络客户端"]
CLI["CLI"]
end
subgraph 内核["harness 内核"]
GW["协议网关:翻译 / 路由 / 持久化"]
end
subgraph 内侧["内侧:模型世界"]
MODEL["Responses API<br/>(SSE / WebSocket)"]
end
TUI -->|"Op(命令)"| GW
IDE -->|"Op(JSON-RPC)"| GW
CLI -->|"Op"| GW
GW -->|"Event(事件)"| TUI
GW -->|"通知 / 反向请求"| IDE
GW -->|"Event"| CLI
GW -->|"采样请求(上下文+工具)"| MODEL
MODEL -->|"流式事件(delta / item / 完成)"| GW
2.2 前端协议:Op 是祈使句,Event 是陈述句
前端与内核之间的全部对话,由两个枚举穷尽:
- Op(操作):前端 → 内核。祈使句——“做这件事”。
- Event(事件):内核 → 前端。陈述句——“发生了这件事”。
第一章已经按用途给 Op 分过类(输入类、控制类、审批应答类、维护类)。Event 这边种类更多,值得按“前端拿它干什么”重新归一次类:
| 事件类别 | 代表事件 | 前端拿来做什么 |
|---|---|---|
| 轮次生命周期 | 轮次开始、轮次完成、轮次中止、关闭完成 | 驱动状态机(第一章 1.4 的状态图就是从它们推导的) |
| item 生命周期 | item started、item completed(携带结构化 item:user 消息、assistant 消息、reasoning、命令执行、文件改动、压缩记录……) | 渲染对话主体;这是权威数据源 |
| 流式 delta | assistant 文本 delta、reasoning summary delta、raw reasoning delta、plan delta | 打字机效果;易失、只用于实时渲染 |
| 工具进度 | 命令开始/输出 delta/结束、终端交互、补丁开始/更新/结束、MCP 调用开始/结束、联网搜索、图片生成 | 工具专属的进度 UI(终端输出流、diff 预览) |
| 审批与提问请求 | 命令审批请求、补丁审批请求、权限申请、向用户提问、MCP 输入表单、动态工具调用请求 | 弹出审批框/提问框;期待一个应答 Op |
| 状态与旁路 | token 用量计数、模型改道、流错误(重连中)、警告、错误、安全缓冲、环境连接、MCP 启动进度、压缩完成、回滚完成、hook 开始/完成 | 状态栏、警告条、toast、成本面板 |
| 原始透传 | 原始模型 item、原始响应完成(含精确 token 用量) | 给需要模型协议原始数据的新前端/扩展用 |
| 多 agent / 实时语音 | 子 agent 活动、协作事件族;实时语音会话事件族 | 子 agent 面板、语音通话 UI |
几个设计要点:
1. 事件说“事实”,不说“指令”。 事件描述的是“发生了什么”(一条 assistant 消息完成了、一条命令开始执行了),而不是“你应该把界面刷成什么样”。同一个事件,TUI 可以渲染成气泡,app-server 可以转发成 JSON-RPC 通知,持久化层可以写进日志——消费者各取所需。这是事件能同时服务渲染、状态推导、持久化三个用途的根本原因(第一章结尾留下的思考题,答案就在这里:事件必须是事实的投影,而不是某个 UI 的遥控指令)。
2. 事件携带完整的坐标。 每个事件都带着轮次 ID,多数还带线程 ID 和 item ID。前端是多线程、多轮次、多 item 并发的世界,没有坐标的事件无法被安放。轮次 ID 就是触发该轮次的那条提交的 ID(时间有序的 UUIDv7),所以前端不需要维护自己的“当前轮次”映射——事件自己会声明归属。
3. 入站有界、出站无界。 前端 → 内核的提交通道是有界的(容量 512):命令生产太快时,提交方会被阻塞或收到背压,防止命令无限堆积。内核 → 前端的事件通道是无界的:内核绝不应该因为“前端看得慢”而丢失事件或阻塞工作——事件是真相,真相不能丢。真正的流量控制在更外层解决(app-server 对网络客户端有有界队列和过载错误,见 2.7)。
4. 命令有“信封”。 每条提交不只装一个 Op,还附带:唯一 ID(用于和事件关联)、可选的 W3C 追踪上下文(traceparent,跨越异步边界串联分布式链路,第 12 章可观测性会用到)、以及多 agent 场景下的父轮次/根轮次 ID(子 agent 的消息要能追溯到是谁触发的,第 7 章展开)。
类比:Op/Event 协议像一个餐厅的“点餐-出餐”系统。Op 是客人递进去的小票(点餐、催单、换菜、结账),Event 是后厨向外的广播(3 号桌的菜下锅了、3 号桌的菜上了、3 号桌等的鱼没有了需要换一个——请确认)。小票有编号,广播喊桌号,两相对得上。
2.3 三种对话模式:发后不管、确认即回、问与答
Op/Event 虽然是两个方向的异步消息,但它们组合出了三种不同的对话模式。混淆这三者,是理解协议时最容易犯的错。
模式一:发后不管(fire-and-forget)。 大多数 Op 如此——中断、刷新配置、清理后台终端。前端发出后不期待任何专门的“收到”回复;内核处理后产生的事件本身就是结果(比如中断 Op 最终会引出“轮次中止”事件)。
模式二:确认即回(acknowledge, not result)。 用户输入是最微妙的 Op。前端需要立刻知道“我的消息被接受了吗”,但显然不能等轮次跑完才回复——那可能要几分钟。内核的做法是:输入 Op 携带一个一次性应答通道,内核只回复路由决策——“已开新轮次”“已作为插话注入”或“被拒绝”(忙且不能插话、输入为空等)。这个回复在毫秒级返回;之后轮次的整个生命周期通过常规事件流汇报。
sequenceDiagram
participant H as 前端
participant C as 内核
H->>C: Op:用户输入(带应答通道)
Note over C: 路由决策:空闲?开新轮次<br/>忙?插话 / 拒绝
C-->>H: 立即回复:已开新轮次(轮次 ID)
Note over H: 拿到确认,界面上立刻显示用户消息
C->>H: Event:轮次开始
C->>H: Event:文本 delta……
C->>H: Event:轮次完成
这个切分非常关键:“接受请求”和“完成请求”是两个独立的信号。 前端 UI 可以在收到确认的瞬间就把用户消息画上屏(乐观渲染),而不必等待任何模型输出。
模式三:问与答(request-response,方向反转)。 第一章 1.3 已经见过:内核需要授权或提问时,发出一个审批/提问请求事件,然后在轮次内部挂起一个一次性等待通道;前端的应答以专门的应答 Op 回来(批准命令、批准补丁、回答提问、提交权限授予、填写 MCP 表单……),循环取出后唤醒挂起的工具调用。
sequenceDiagram
participant H as 前端
participant C as 内核(提交循环)
participant T as 轮次任务
T->>H: Event:审批请求(命令详情、请求 ID)
Note over T: 挂起等待……
Note over H: 用户在弹窗里点"批准"
H->>C: Op:审批应答(请求 ID + 决定)
C->>T: 通过等待通道唤醒
Note over T: 命令执行,轮次继续
第三种模式的深意在于:“内核向前端发请求”是协议的一等公民,不是特例。 审批、向用户提问、权限申请、MCP 表单、动态工具(把工具调用委托给前端执行)——人机协同、前端能力扩展,全部复用这同一个“反转的请求-应答”。第 8 章安全策略和第 11 章可扩展性都会回到它。
此外还有一条状态广播通道独立于事件流:代理状态(空闲/运行中/已中断……)用一个 watch 通道发布,新订阅者立刻拿到当前值,之后收到变化推送。它表达的是“此刻的状态”,而事件流表达的是“发生过的事”——状态是事件流的投影,但为了方便随时查询,单独暴露了一份最新值。
2.4 模型协议:一次请求,一条事件流
现在把椅子转向内侧。模型说的是 Responses API 的语言。
2.4.1 请求:把整个“世界”打包
一次采样请求的本质,是把 harness 此刻掌握的全部相关信息打成一个请求体:
- 输入历史:一串有序的 item——用户消息、assistant 消息、reasoning 记录、工具调用及其结果、各种带标记的环境片段(第 4 章的主题);
- 指令:系统级基础指令(工具怎么用、行为准则);
- 工具清单:这一步对模型可见的工具及其 JSON Schema(第 6 章的主题);
- 推理控制参数:reasoning effort(推理强度)、是否要 reasoning summary、并行工具调用开关等(第 3 章展开);
- 以及模型名、缓存键、结构化输出 schema 等。
请求发出后,回复不是一个对象,而是一条服务器推送事件流(SSE)——或者在支持时走一条复用的 WebSocket 连接。内核像读日志一样逐行读取事件,直到流的终点。
2.4.2 流的结构:item 为骨,delta 为肉
模型流的事件可以按“骨架”和“血肉”分开理解。
骨架是 item 的生命周期。一次响应产出若干个输出 item(item,即流中的一个完整输出单元:一段 assistant 消息、一段 reasoning、一个工具调用……),每个 item 经历“添加 → 完成”:
response.created:响应开始;response.output_item.added:一个新 item 出现(此时知道它的类型和 ID,但内容还空着);response.output_item.done:这个 item 完整了(全文本、完整的工具调用名和参数);response.completed:整个响应结束,附带 token 用量统计和一个关键标志位end_turn。
血肉是 item 内部的 delta(delta,即流式增量片段),在 added 和 done 之间持续到达:
- assistant 文本 delta(
output_text.delta):一个个字片段,打字机效果的来源; - reasoning summary delta(
reasoning_summary_text.delta):模型愿意展示的那部分思考摘要,还分“段落”(part),每个段落一个标题块; - raw reasoning delta(
reasoning_text.delta):加密的原始思维链(chain-of-thought,CoT)。模型厂商不希望思维链明文离开服务端,但为了多轮一致性,允许它以加密形式随上下文回传——内核只负责搬运,永远看不到明文。
sequenceDiagram
participant C as 内核
participant M as 模型服务
C->>M: 采样请求(历史 + 工具 + 指令)
M-->>C: created(响应开始)
M-->>C: output_item.added(reasoning item)
M-->>C: reasoning summary delta ×N
M-->>C: output_item.done(reasoning item 完成)
M-->>C: output_item.added(assistant 消息 item)
M-->>C: 文本 delta ×N
M-->>C: output_item.done(assistant 消息完成)
M-->>C: output_item.added(工具调用 item)
M-->>C: 工具参数 delta(仅自由格式工具)
M-->>C: output_item.done(工具调用成形)
Note over C: 工具立刻开始执行(不必等流结束)
M-->>C: completed(token 用量 + end_turn 标志)
Note over C: 回灌工具结果,决定是否再次采样
注意 completed 里的 end_turn 标志:它表示模型是否明确认为“我这轮说完了”。如果模型在回复里调了工具,或者标志位显式为 false,内核就知道还要再发起一次采样(工具结果回灌后继续);否则轮次收尾。轮次是否结束,最终由这个标志和工具调用情况共同决定——呼应第一章 agentic 主循环的契约:“要么工具调用,要么最终回复”。
2.4.3 那些被故意忽略的事件
模型流里其实还有不少事件类型,内核显式地丢弃了它们(只记日志,不处理):
response.in_progress:created 之后的冗余状态;response.output_text.done、content_part.added/done:delta 流完时的“收尾”事件,内容和 done item 重复;response.function_call_arguments.delta:普通工具调用的参数 JSON delta。
最后一个尤其值得一问:工具参数也是一个字一个字生成的,为什么不像文本一样做流式展示?
因为半截 JSON 对人没有意义,还可能误导。普通工具(执行命令、读文件)的参数是结构化数据,在闭合之前它不是合法 JSON,渲染出来只是一串括号和引号——用户既看不懂,也无法据此预览任何东西。内核选择等工具调用整体成形(output_item.done)后,再靠工具自己执行期间发出的进度事件(命令输出流、补丁预览)来展示。
那为什么补丁工具(apply_patch)例外?因为它是自由格式工具(freeform tool):参数不是 JSON,而是一整段人类可读的补丁文本。补丁是逐行生成的,每一行都有意义——内核能边接收边解析,把“将要改动哪些文件、每个文件改成什么样”实时预览出来(模型流里对应 custom_tool_call_input.delta 事件,内核翻译成补丁更新事件,还做了 500ms 节流避免刷爆界面)。
这是一个普适的协议设计教训:不是所有“能流式”的数据都“值得流式”。delta 通道只承载对消费者有独立意义的片段。
2.4.4 错误:流上的“异常项”,而不是事件
模型协议里一个反直觉的设计:错误不是事件,而是流上的一个 Err。
流的类型是“事件的序列”,但每个元素是 Result<事件, 错误>。流可能以这些方式异常终止:
- 网络断连、超时、流在
completed之前意外关闭; response.failed:服务端明确报告失败,携带错误码;response.incomplete:响应不完整(比如被安全策略截断),附带原因。
错误还分致命和可重试两类,语义完全不同:
| 错误 | 性质 | 内核的反应 |
|---|---|---|
| 上下文超长(context window exceeded) | 致命 | 不重试,交给压缩逻辑处理(第 4 章) |
| 配额/用量耗尽 | 致命 | 不重试,向用户报告额度问题 |
| 安全策略拦截(cyber policy 等) | 致命 | 不重试,转错误事件 |
| 请求非法(invalid request) | 致命 | 不重试,这是 bug 或不支持的用法 |
| 限流(rate limit)、服务过载、网络抖动、流中断 | 可重试 | 指数退避后重发请求 |
为什么错误用 Result 而不是一个“错误事件”?因为模型流只有一个消费者(内核),错误是控制流而不是数据。Rust 的 Result 天然表达“流在这里断了”,消费者无法忽略它(不处理错误就拿不到后面的事件——后面本来也没有了)。对比前端协议:事件有很多消费者(UI、状态机、持久化、app-server 的多个客户端),错误必须作为一条数据广播出去,让每个消费者各自决定怎么呈现。
同一个概念,在一对一的流上是异常,在一对多的总线上是消息。
2.4.5 重试:对轮次透明,对用户可见
可重试错误触发重试循环:传输层在连接阶段就有重试(默认 4 次,指数退避加随机抖动,避免重试风暴);流已经开始后断掉,还有流级重试(默认 5 次)。WebSocket 传输重试耗尽时,会自动回退到 HTTP SSE 再来一轮。
重试期间轮次不结束、状态不变。前端会收到咨询性的“流错误”事件(文案是“重连中…… 2/5”),UI 可以显示一个低调的提示;重试成功,事件流继续,像什么都没发生过。
这里有一个和第 2.2 节呼应的重要取舍:delta 通道不保证可靠,权威通道保证可靠。 重试时请求是用持久化的历史重新组装的——已完成的 item(工具调用、工具结果)在成形的那一刻就已经落历史、落持久化,所以重试不丢工作;但已经吐给前端的 delta 不会重放。如果前端在断连期间漏了几个字片段,它不会被补发——前端以 item completed 事件里的完整内容为准。delta 只是实时渲染的加速带,item 才是事实本身。这就是为什么 2.2 节把事件分成“流式 delta(易失)“和”item 生命周期(权威)“两类:这个分类不是随意的,它对应着失败恢复时的两种待遇。
2.4.6 取消:没有取消消息,断开就是取消
模型协议里找不到“取消”这个动作。中断发生时(第 1.5 节的协作式取消),内核的做法是:取消令牌触发 → 读取流的 future 被放弃 → 流对象被 drop → 底层连接关闭。服务端发现连接断了,自然停止生成。
这是分布式系统的典型哲学:取消是连接层面的事实,不需要应用层消息。 设计一条“请取消”的消息反而引入新问题(消息丢了怎么办?服务端忙着生成没看到怎么办?)。TCP 连接的关闭本身就是最可靠的取消信号。
对比前端侧:中断是一个显式 Op。为什么这边需要消息?因为内核不能被“一枪毙掉”——它要做协作式收尾(给任务优雅期、追加中断标记、保留后台进程、发出中止事件)。对模型,内核只需要它“闭嘴”;对前端,内核需要“体面地停下并交代清楚”。 关系再次决定了协议的形状。
2.4.7 两种传输:SSE 与 WebSocket
模型流有两种传输方式,共享同一套事件格式:
- HTTP SSE:一次 POST 请求,响应是事件流。简单、通用、兼容所有 provider;每次采样都是独立请求。
- WebSocket:与服务端建立一条长连接,多次采样复用它,请求可以是增量的(incremental,只发新增的历史,配合服务端的
previous_response_id),还支持预热(连接上先发起一个不生成的请求,让服务端把上下文缓存好)。连接有 60 分钟上限,到期按错误码重连;出问题可整体回退到 SSE。
WebSocket 模式下还有一个细节:轮次开始时服务端会通过响应头发下一个粘性路由令牌(turn-state token),同一轮次内的后续请求(重试、续接)都带上它,保证同一轮对话被路由到服务端同一后端实例——那里缓存着这轮的上下文。令牌严禁跨轮次复用:新轮次换新令牌,旧令牌随轮次结束而失效。这是第 4 章“上下文缓存”主题在协议层的伏笔:harness 一边在本地增量累积历史,一边配合服务端的缓存亲和性,两边共同压低重复计算。
2.5 翻译层:内核如何把模型流“说成人话”
两条协议并不直接对话,中间隔着内核的翻译层。每收到一个模型流事件,翻译层决定:发什么前端事件、记什么历史、是否触发动作。
flowchart TD
SE["模型流事件"] --> T{"事件类型"}
T -->|"item added(消息/reasoning)"| A["发:item started(权威渲染起点)"]
T -->|"文本 delta"| B["发:assistant 文本 delta<br/>(剥离引用标记、计划块等噪声)"]
T -->|"reasoning summary delta"| C["发:reasoning delta / 段落分隔"]
T -->|"raw reasoning delta"| D["发:raw reasoning delta"]
T -->|"item done(消息/reasoning)"| E["记入历史 + 发:item completed"]
T -->|"item done(工具调用)"| F["立即记入历史<br/>spawn 工具并行执行<br/>工具自己发进度事件"]
T -->|"补丁参数 delta"| G["边收边解析 → 发:补丁更新预览"]
T -->|"completed"| H["发:原始响应完成 + token 用量<br/>后补发:用量计数事件"]
T -->|"服务端模型与请求不符"| I["发:模型改道警告(每轮一次)"]
T -->|"安全缓冲 / 限流 / 验证建议"| J["发:对应旁路通知"]
T -->|"错误 Err"| K["可重试?发:重连中 → 退避重试<br/>致命?发:错误事件,轮次失败"]
翻译层有几个值得驻足的设计:
1. 工具调用在“成形”的那一刻就启动,不等流结束。 工具 item done 时,内核立刻把它记入历史(即使轮次之后被取消,历史也保持完整)并 spawn 执行——工具执行与模型继续输出后续内容在时间上重叠。流结束后,内核再严格按模型发出调用的顺序回收工具结果(第一章 1.4 的“并行执行、按序回灌”)。协议层面这意味着:工具的生命周期事件(命令开始、输出 delta、结束)是穿插在模型文本流事件里的,前端必须接受这种交错,并靠事件上的 item ID/轮次 ID 把它们归位。
2. 翻译会“清洁”模型输出。 模型的文本 delta 里夹杂着一些给机器看的标记:引用来源标记、计划模式下的计划块等。翻译层在流式阶段就把它们解析剥离——计划文本走专门的计划事件,引用标记附加到最终消息的元数据里,前端看到的 assistant 文本是干净的。模型协议是给模型生态用的,前端事件是给人用的,两者之间的清洁工作由翻译层承担。
3. 旁路信息各有去处。 响应头和流里还夹带各种元信息:实际服务的模型名(安全路由可能把高风险请求改道到别的模型,内核发“模型改道”事件,每轮至多一次)、安全缓冲状态(服务端安全审查期间输出被缓冲,UI 显示“审查中”,必要时换更快的模型重试)、限流配额快照(攒到响应结束后随用量事件一起发,避免刷屏)、账号验证建议等。它们不进模型历史,只作为旁路通知存在。
4. 每个完成的 item 都“双发”。 一个 item 完成时,前端同时收到两类东西:一个结构化 item 事件(翻译层整理过的、语义化的 item——用户消息、assistant 消息、命令执行、文件改动……),以及一个原始 item 事件(未经加工的模型协议原生对象)。前者给 UI 渲染用,后者给需要原始数据的消费者用。下一节解释这个“双发”的由来。
2.6 三代事件并存:协议如何演进
一个活的协议必须能演化。Codex 的事件体系里实际上沉淀着三代事件,理解它们的共存关系,才能理解为什么协议长成今天这样:
第一代:整消息事件。 最早的事件是“整条消息”粒度的:一条 assistant 消息事件(携带完整文本)、一条 reasoning 事件、命令开始/结束。简单,但无法做流式 UI——消息不到齐就什么都显示不了。
第二代:delta + item 生命周期。 为了打字机体验,引入了 delta 事件(文本 delta、reasoning delta);随后又引入了更规整的 item started/completed 模型:轮次由若干 item 组成,每个 item 有明确的开始和完成,delta 只是 item 内部的流动。item 是权威的、完整的、可持久化的;delta 是易失的渲染加速带(2.4.5 的重试语义就建立在这个区分上)。
第三代:原始透传事件。 app-server 这类新前端希望自己掌握解释权——它们要的是模型协议里的原始 item,而不是内核整理过的语义视图。于是每个写入历史的 item 都原样透传一份(原始 item 事件),响应结束时还有原始完成事件(携带服务端报告的精确 token 用量,不估算、不累计)。
三代不是替换关系,而是叠加关系:内核在发送第二代规范事件时,会自动派生出第一代事件(老客户端不改代码也能继续工作);同时旁路透出第三代原始事件(新客户端各取所需)。事件只增不废,旧事件从“核心契约”降级为“兼容投影”。
graph TD
subgraph 内核翻译层
CANON["规范事件(第二代)<br/>item started/completed + delta"]
end
CANON -->|"自动派生"| LEGACY["第一代事件<br/>整消息 / 命令开始结束"]
CANON --> RAW["第三代事件<br/>原始 item 透传"]
HIST["历史记录(写入时)"] --> RAW
LEGACY --> OLD["旧版前端:TUI 旧版 / 旧 rollout"]
CANON --> NEW["新前端:结构化 UI"]
RAW --> EXT["app-server v2 / 扩展 / 成本核算"]
这个策略的代价是内核里多了一层“事件扇出”逻辑(每条规范事件都过一遍派生函数),收益是协议升级不造成生态断裂:持久化的历史事件流(第 10 章会讲,rollout 是唯一真相源)里可能躺着十年间各版本的事件,反序列化时新代码必须全部读得懂——#[serde(default)]、字段别名、旧格式兼容转换在协议类型里随处可见。事件一旦发出就是永恒的,因为它会被持久化、被回放、被跨版本读取。 这是事件协议和普通函数 API 最大的不同:函数签名改了,编译期就能发现所有调用方;事件格式改了,磁盘上三年前的日志不会自己更新。
2.7 前端形态:同一套语义,三种传输
Op/Event 是语义协议,不绑定传输方式。同一件事(“用户输入”→“轮次开始”→ delta → “轮次完成”)在三种前端形态下字节形态完全不同,但消息模型完全一致:
形态一:进程内直连(TUI、CLI)。 前端和内核在同一进程里,消息走内存通道,没有 JSON 序列化。有趣的是,TUI 现在并不直接对接内核的 Op/Event,而是接入一个进程内 app-server:TUI 发出的请求、收到的通知,与网络客户端跑的是同一套 JSON-RPC 语义契约(强类型请求、通知信封、反向请求),只是底层传输从 socket 换成了内存通道、握手自动完成。这样 TUI 和 IDE 插件共享同一套协议行为,差异在开发期就被消弭,而不是靠“两个客户端各自理解一遍内核”来维持一致。app-server 再往下,才翻译成内核的 Op/Event。
形态二:标准输入输出(本地 IDE 插件)。 前端 spawn 一个 app-server 子进程,通过 stdio 交换换行分隔的 JSON(一行一条消息)。这是最通用的本地形态:任何语言、任何进程都能参与,不占端口,天然随父进程生死。
形态三:网络服务(WebSocket / Unix socket)。 app-server 监听端口或本地 socket,支持多个客户端同时连接。多连接带来了单进程形态没有的新问题,协议为此增加了几样东西:
- 握手:连接建立后先交换 initialize / initialized(客户端报上身份和能力,服务端回报环境信息),握手前拒绝一切请求;
- 能力协商与实验门控:客户端声明是否接受实验性 API;实验方法/字段对未声明的客户端隐藏或报错,schema 分稳定版/实验版两套导出;
- 订阅模型:客户端通过“启动/恢复/分叉线程”自动订阅该线程的事件流,也可显式退订;通知按连接扇出,客户端还能按方法名精确屏蔽自己不关心的通知;线程在最后一个订阅者离开后保留一段时间才卸载;
- 反向请求:服务端也能向客户端发请求(审批、提问、表单、动态工具),客户端必须应答——2.3 节的“问与答”模式在网络上变成正式的双向 RPC;轮次结束时未决的反向请求会被明确中止,不会永远挂着;
- 背压与过载:入站、处理、出站之间都是有界队列;服务器忙不过来时新请求收到明确的“过载,请重试”错误(而不是静默排队或丢消息);但反向请求绝不允许静默丢弃——审批请求丢了会导致轮次永久挂起,所以宁可失败返回也不吞掉。
sequenceDiagram
participant C1 as 客户端 A(IDE)
participant C2 as 客户端 B(面板)
participant S as app-server
participant K as 内核
C1->>S: initialize(身份 + 能力)
S-->>C1: 环境信息
C1->>S: initialized(通知)
C1->>S: thread/start → turn/start(用户输入)
S->>K: Op:用户输入
K-->>S: 确认:已开轮次
S-->>C1: turn/started
C2->>S: initialize + thread 订阅
K->>S: Event:文本 delta
S-->>C1: 通知:item/agentMessage/delta
S-->>C2: 通知:item/agentMessage/delta
K->>S: Event:审批请求
S-->>C1: 反向请求:requestApproval
Note over C2: 审批只定向发给相关连接
C1->>S: 应答:批准
S->>K: Op:审批应答
方法命名也反映了“资源/动作”的 REST 式风格:thread/start、turn/steer、turn/interrupt、config/read、fs/readFile…… 字段统一 camelCase,时间戳用 Unix 毫秒。内核的 Op/Event 到这些 JSON-RPC 方法之间有一层薄薄的投影:多数事件是一对一翻译(轮次开始 → turn/started,文本 delta → item/agentMessage/delta),少数需要聚合状态的(如轮次完成时汇总最终状态和用量)在 app-server 层完成。
2.8 小结:协议设计的六条原则
-
协议形状由对话关系决定。 前端是需要协商的同事:命令要确认、提问要回答、取消要体面收场,所以前端协议是丰富的双向消息总线。模型是“启动即输出”的引擎:流只读一次、错误即中断、取消即断连,所以模型协议极简。内核是两者之间的网关。
-
事件陈述事实,不发布指令。 同一条事件流同时驱动 UI 渲染、状态推导、持久化回放,因此事件必须是“发生了什么”的事实投影,而不是给某个 UI 的遥控信号。事件自带线程/轮次/item 坐标,消费者自行归位。
-
权威与易失分层。 item(完整、成形、落历史)是权威的,可重放、可重建;delta(片段、流式)是易失的渲染加速带,失败重试时不补发。任何消费者都必须能仅凭 item 事件重建完整 UI——这个约束让断连重试、迟到订阅、历史回放都变得简单。
-
错误按“关系”选择表达方式。 一对一的流上,错误是
Result(无法忽视的控制流);一对多的总线上,错误是事件(广播给所有消费者的数据)。致命错误与可重试错误严格区分,重试对轮次状态透明、对用户可见(“重连中”),且不丢已完成的工作。 -
反转的请求-应答是一等公民。 内核向前端发请求(审批、提问、表单、委托执行)与前端向内核发命令共用同一套消息机制。人机协同和能力扩展因此不是补丁,而是协议的原生形态。网络传输下它还配套了“必须应答、不可丢弃、轮次结束即中止”的严格语义。
-
协议只增不废,事件即永恒。 三代事件(整消息、item+delta、原始透传)叠加共存,新事件自动派生旧事件;事件会被持久化并跨版本回放,所以兼容性是协议类型的硬约束——这也是第 10 章“事件溯源持久化”的前提。
留给读者思考的几个问题:
- 模型流里“错误是 Err 不是事件”,前端协议里“错误是事件”——如果让模型流也把错误做成事件,会多出哪些失败模式?(提示:流只有一个消费者这件事意味着什么?)
- delta 不保证可靠、item 保证可靠——如果一个前端只订阅 delta 而忽略 item completed 事件,在重试/重连场景下会看到什么?这对 UI 代码的状态管理提出了什么要求?
- 审批请求“绝不允许静默丢弃”,而普通通知在服务器过载时可以被丢弃或拒绝——为什么协议对这两类流量给出相反的可靠性承诺?(→ 第 8、9 章)
- TUI 为什么要“自降身段”走进程内 app-server,而不是直接调内核?多一层协议转换换来的是什么?(→ 第 11 章)
- 事件格式要兼容十年前的持久化日志,而工具清单、模型能力每个月都在变——harness 如何在“协议冻结”和“能力快速演化”之间腾挪?(→ 第 6 章工具暴露面、第 11 章扩展机制)
下一章我们进入模型接入与推理控制:采样请求里那些参数(推理强度、摘要、工具选择、缓存键、结构化输出)如何影响模型行为,harness 又如何在模型故障、限流、改道时维持轮次的连续性。
想自己写前端接入? 附录 A《前端对接协议参考》给出了 app-server JSON-RPC 的完整消息目录、字段约定、审批反向请求时序和最小客户端骨架,可直接作为对接手册使用。