<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Protocol on 扎塔-Zata</title><link>https://www.zata.cc/tags/protocol/</link><description>Recent content in Protocol on 扎塔-Zata</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>Example Person</copyright><lastBuildDate>Mon, 07 Sep 2026 18:50:28 +0800</lastBuildDate><atom:link href="https://www.zata.cc/tags/protocol/index.xml" rel="self" type="application/rss+xml"/><item><title>Agent Run 流式协议：事件溯源、SSE 投影与断线恢复</title><link>https://www.zata.cc/p/agent-run-%E6%B5%81%E5%BC%8F%E5%8D%8F%E8%AE%AE%E4%BA%8B%E4%BB%B6%E6%BA%AF%E6%BA%90sse-%E6%8A%95%E5%BD%B1%E4%B8%8E%E6%96%AD%E7%BA%BF%E6%81%A2%E5%A4%8D/index.md/</link><pubDate>Mon, 07 Sep 2026 10:30:00 +0800</pubDate><guid>https://www.zata.cc/p/agent-run-%E6%B5%81%E5%BC%8F%E5%8D%8F%E8%AE%AE%E4%BA%8B%E4%BB%B6%E6%BA%AF%E6%BA%90sse-%E6%8A%95%E5%BD%B1%E4%B8%8E%E6%96%AD%E7%BA%BF%E6%81%A2%E5%A4%8D/index.md/</guid><description>&lt;img src="https://www.zata.cc/p/agent-run-%E6%B5%81%E5%BC%8F%E5%8D%8F%E8%AE%AE%E4%BA%8B%E4%BB%B6%E6%BA%AF%E6%BA%90sse-%E6%8A%95%E5%BD%B1%E4%B8%8E%E6%96%AD%E7%BA%BF%E6%81%A2%E5%A4%8D/index.md/images/index/index.svg" alt="Featured image of post Agent Run 流式协议：事件溯源、SSE 投影与断线恢复" />&lt;p>很多 Agent 系统的流式输出，本质上是把模型服务的 chunk 直接转发给浏览器。这样做上线很快，但一旦遇到刷新页面、断线重连、审计和取消，就会立刻发现：&lt;strong>前端消费的不是一个可恢复的事件流，而是一条一次性管道。&lt;/strong>&lt;/p>
&lt;p>我现在的做法是把协议拆成三层：Runtime 先产出领域事件，Adapter 统一映射成 canonical 事件并落库，最后由 SSE 端点从事件库里做投影。这篇文章记录这套协议的实际形态，以及它为什么这样设计。&lt;/p>
&lt;h2 id="一三层结构">一、三层结构
&lt;/h2>&lt;p>整条链路可以这样理解：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">Runtime / Runner
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ AgentEvent 执行器内部事件
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ Adapter 映射
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ AgentRunEvent canonical 事实，落库并分配 seq
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ SSE 端点 committed event 的投影
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ 前端
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="runner-端口层">Runner 端口层
&lt;/h3>&lt;p>&lt;code>AgentEvent&lt;/code> 是 Runtime 对外的最小事件模型，目前有 7 种类型：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>类型&lt;/th>
&lt;th>专用字段&lt;/th>
&lt;th>含义&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>state_change&lt;/code>&lt;/td>
&lt;td>&lt;code>state&lt;/code>&lt;/td>
&lt;td>Runtime 内部状态迁移&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>delta&lt;/code>&lt;/td>
&lt;td>&lt;code>content&lt;/code>&lt;/td>
&lt;td>模型文本增量&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool_call&lt;/code>&lt;/td>
&lt;td>&lt;code>tool_name&lt;/code> / &lt;code>tool_call_id&lt;/code> / &lt;code>tool_args&lt;/code>&lt;/td>
&lt;td>工具调用开始&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool_result&lt;/code>&lt;/td>
&lt;td>&lt;code>tool_name&lt;/code> / &lt;code>tool_call_id&lt;/code> / &lt;code>tool_result&lt;/code>&lt;/td>
&lt;td>工具结果摘要&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>artifact&lt;/code>&lt;/td>
&lt;td>&lt;code>artifact&lt;/code>&lt;/td>
&lt;td>结构化产物元数据&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>done&lt;/code>&lt;/td>
&lt;td>&lt;code>answer&lt;/code>&lt;/td>
&lt;td>最终答案全文&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>error&lt;/code>&lt;/td>
&lt;td>&lt;code>error_code&lt;/code> / &lt;code>error_message&lt;/code>&lt;/td>
&lt;td>失败&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>这一层有意做薄。不管 Runtime 是 LangGraph、DeepAgents，还是新接的沙箱执行器，只要转换成这 7 类事件，后端后面的所有逻辑都不用重复实现。&lt;/p>
&lt;h3 id="canonical-层">Canonical 层
&lt;/h3>&lt;p>Adapter 把 Runtime 事件映射成 canonical 事件。canonical 层不是照抄 Runtime 词汇，而是补齐运行生命周期：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Canonical 事件&lt;/th>
&lt;th>来源&lt;/th>
&lt;th>payload 要点&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>run.started&lt;/code>&lt;/td>
&lt;td>Adapter 生成&lt;/td>
&lt;td>&lt;code>started_at&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>message.started&lt;/code>&lt;/td>
&lt;td>Adapter 生成&lt;/td>
&lt;td>&lt;code>message_id&lt;/code>、&lt;code>role&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>message.delta&lt;/code>&lt;/td>
&lt;td>&lt;code>delta&lt;/code>&lt;/td>
&lt;td>&lt;code>message_id&lt;/code>、&lt;code>content_index&lt;/code>、&lt;code>delta&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool.call.started&lt;/code>&lt;/td>
&lt;td>&lt;code>tool_call&lt;/code>&lt;/td>
&lt;td>&lt;code>tool_call_id&lt;/code>、&lt;code>tool_name&lt;/code>、&lt;code>arguments&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool.call.completed&lt;/code>&lt;/td>
&lt;td>&lt;code>tool_result&lt;/code>&lt;/td>
&lt;td>&lt;code>result&lt;/code>、&lt;code>result_checksum&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>artifact.created&lt;/code>&lt;/td>
&lt;td>&lt;code>artifact&lt;/code>&lt;/td>
&lt;td>产物 metadata&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>message.completed&lt;/code>&lt;/td>
&lt;td>&lt;code>done&lt;/code>&lt;/td>
&lt;td>&lt;code>content&lt;/code>、&lt;code>content_checksum&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>run.completed&lt;/code>&lt;/td>
&lt;td>&lt;code>done&lt;/code> 后置&lt;/td>
&lt;td>&lt;code>message_id&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>run.failed&lt;/code>&lt;/td>
&lt;td>&lt;code>error&lt;/code>&lt;/td>
&lt;td>&lt;code>error.code&lt;/code>、&lt;code>error.message&lt;/code>、&lt;code>error.retryable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>run.cancelled&lt;/code>&lt;/td>
&lt;td>取消路径&lt;/td>
&lt;td>&lt;code>external_stop_confirmed&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>落库后的 envelope 长这样：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;run_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;run_xxx&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;seq&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;message.delta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;occurred_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-09-07T10:30:00.123456&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;recorded_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-09-07T10:30:00.126000&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;agent_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;freight_agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;agent_snapshot_checksum&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;payload&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;message_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;message_xxx&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;delta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;payload_checksum&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;schema_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;span_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这里最重要的是 &lt;code>seq&lt;/code>。Runtime 事件本身不携带权威序号；Adapter 只提交 candidate，repository 落库时按 &lt;code>last_event_seq + 1&lt;/code> 分配。&lt;strong>事件一旦提交，顺序就是系统事实。&lt;/strong>&lt;/p>
&lt;h2 id="二sse-wire-format">二、SSE wire format
&lt;/h2>&lt;p>对外入口是：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-http" data-lang="http">&lt;span class="line">&lt;span class="cl">&lt;span class="err">GET /api/agent-runs/{run_id}/events
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SSE 使用命名帧：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">id: 4
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">event: message.delta
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">data: {&amp;#34;run_id&amp;#34;:&amp;#34;run_xxx&amp;#34;,&amp;#34;seq&amp;#34;:4,&amp;#34;event_type&amp;#34;:&amp;#34;message.delta&amp;#34;,...}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>几个关键语义：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>重放优先。&lt;/strong> 客户端可以用 &lt;code>?after_seq=&lt;/code> 查询参数，也可以用标准 SSE 的 &lt;code>Last-Event-ID&lt;/code> 请求头；服务端取较大值，然后从事件库里回放。&lt;/li>
&lt;li>&lt;strong>游标安全。&lt;/strong> 游标为负，或大于当前 &lt;code>last_event_seq&lt;/code>，返回 422，避免客户端拿着错误游标等一个永远不会来的事件。&lt;/li>
&lt;li>&lt;strong>实时推送。&lt;/strong> 端点每 250ms 从 committed repository 拉取新事件；发现序号 gap 就断流，不静默跳过。&lt;/li>
&lt;li>&lt;strong>keep-alive。&lt;/strong> 空闲 15 秒发送 SSE 注释帧 &lt;code>: keep-alive&lt;/code>。&lt;/li>
&lt;li>&lt;strong>自然终止。&lt;/strong> Run 进入终态且事件全部送完，连接主动关闭。&lt;/li>
&lt;/ol>
&lt;p>一次成功 Run 的典型序列：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">id: 1 event: run.started
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 2 event: message.started
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 3 event: message.delta
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 4 event: tool.call.started
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 5 event: tool.call.completed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 6 event: message.delta
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 7 event: artifact.created
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 8 event: message.completed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">id: 9 event: run.completed
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>失败或取消时，终态分别是 &lt;code>run.failed&lt;/code> 或 &lt;code>run.cancelled&lt;/code>。Adapter 用一个终态闸保证三者最多出现一个。&lt;/p>
&lt;h2 id="三为什么先落库再投影">三、为什么先落库，再投影
&lt;/h2>&lt;p>这是整套协议的核心取舍。&lt;/p>
&lt;h3 id="断线恢复变成查询问题">断线恢复变成查询问题
&lt;/h3>&lt;p>客户端刷新页面后，不需要 Runtime 重跑一遍，也不需要服务端缓存整个连接状态。只要带上 &lt;code>Last-Event-ID: 5&lt;/code>，服务端就查询 &lt;code>seq &amp;gt; 5&lt;/code> 的事件继续发。断线恢复从连接层问题退化成数据库查询问题。&lt;/p>
&lt;h3 id="runtime-崩溃不丢执行事实">Runtime 崩溃不丢执行事实
&lt;/h3>&lt;p>事件写入 committed store 之后，Runtime 进程是否还活着，不影响前端重放历史。审计、排障和 UI 恢复消费的是同一份数据。&lt;/p>
&lt;h3 id="多-runtime-共享同一套前端协议">多 Runtime 共享同一套前端协议
&lt;/h3>&lt;p>不同执行器的事件风格可能差异很大。Adapter 负责把差异吸收在入口处，前端只认识 canonical 词汇。新增 Runtime 时，可以避免出现“沙箱一套流、业务 Agent 一套流”的分裂。&lt;/p>
&lt;h3 id="工具调用能稳定配对">工具调用能稳定配对
&lt;/h3>&lt;p>&lt;code>tool_call_id&lt;/code> 优先使用底层模型下发的 id。如果 Runtime 丢了开始事件，Adapter 会补发一个 &lt;code>tool.call.started&lt;/code>，让 UI 仍然成对渲染。这个“配对自愈”很小心地只影响展示完整性，不把 Run 打成失败。&lt;/p>
&lt;h2 id="四当前边界和改进方向">四、当前边界和改进方向
&lt;/h2>&lt;p>这套协议也有几个明显的边界。&lt;/p>
&lt;p>&lt;strong>SSE 出口是客户端胖投影。&lt;/strong> 目前 &lt;code>data&lt;/code> 帧直接序列化完整 envelope，&lt;code>agent_snapshot_checksum&lt;/code>、&lt;code>provenance&lt;/code>、&lt;code>payload_checksum&lt;/code> 等审计字段也被发给前端。短期省事，长期更适合收敛成只包含 &lt;code>seq&lt;/code>、&lt;code>event_type&lt;/code>、&lt;code>occurred_at&lt;/code>、&lt;code>payload&lt;/code> 的客户端投影。&lt;/p>
&lt;p>&lt;strong>实时性靠 250ms 轮询。&lt;/strong> 对文本流足够，但并发观看的连接多了，数据库查询会线性放大。合理演进是加一个进程内或跨实例 pub/sub，只负责“唤醒”轮询，不负责权威事件投递；事件仍然以数据库为准。&lt;/p>
&lt;p>&lt;strong>内容模型只有文本增量。&lt;/strong> &lt;code>message.delta&lt;/code> 目前只有 &lt;code>content_index&lt;/code> 和文本，没有结构化 content block。未来要支持图片、图表卡片或分块富文本，应增量引入 block 类型，而不是扩出新的顶层事件。&lt;/p>
&lt;p>&lt;strong>&lt;code>state_change&lt;/code> 是死词汇。&lt;/strong> Runtime 端口定义了它，但 canonical Adapter 不会映射。后续应该删除或明确语义，避免接入方误以为它会被消费。&lt;/p>
&lt;p>&lt;strong>协议演进规则需要写死。&lt;/strong> 客户端对未知事件类型必须忽略，而不是报错。&lt;code>schema_version&lt;/code> 目前存在，但配套演进策略还要补文档。&lt;/p>
&lt;h2 id="总结">总结
&lt;/h2>&lt;p>这套协议不是行业标准，传输层用的也不是什么新东西，但它把几件事做对了：&lt;strong>事件先成为带序号的事实，SSE 只是投影；Runtime 词汇保持最小，canonical 词汇保持稳定；断线恢复用标准 SSE 游标；工具调用、产物、失败、取消全部事件化。&lt;/strong>&lt;/p>
&lt;p>如果要从这套设计里抽象一条通用经验，那就是：Agent 的远程流式接口不应该让客户端订阅“模型的输出过程”，而应该让客户端订阅“系统已经发生并且可恢复的执行事实”。管道可以丢，事实不应该丢。&lt;/p></description></item></channel></rss>