M4 驱动层 · L10

对话流式链路:一个 token 的旅途与断线不死

45 min · 精读 web/app.py:2051-2470(/api/chat/stream 全流程)

🎒 预备知识:完成上一课「L09」(零基础入口:第 0 课)

本课唯一结论:两个设计撑起整条链路——supervisor/forward 分离(浏览器断开只断转发, agent 照跑到底)和 runId 闩锁(多会话共享一个流文件时认领自己的行)。

一、基础:为什么流式这么绕(问题的根源)

理想很直接:模型吐一个 token,页面显示一个 token。绕在哪?架构上隔着三层: 模型在 gateway 进程里跑,浏览器在 Web 后端之外,中间隔着"怎么把字节流从 A 搬到 B"。 更糟的是 Easel 发现的坑(:2055 docstring 原文大意):openclaw agent CLI 的 stdout 没有增量——它把整段模型输出缓冲到结束才打印。也就是说:走 CLI 路径, 你只能等全部结束才拿到一个完整答案,"流式"物理上不存在。

gateway 侧有解法:设 OPENCLAW_RAW_STREAM=1 环境变量后,gateway 把模型原始流 逐 token 追加写入一个 JSONL 文件。于是 Easel 的流式方案变成: gateway 写文件 → Web 后端 tail 文件 → 转 SSE(Server-Sent Events:浏览器原生的事件推送通道,服务端可单向持续推送)推浏览器。 用文件当进程间的流管道——看起来土,实则稳:进程崩溃文件还在(可重放)、 多读者天然支持、零新依赖。

二、一个 token 的完整旅途

gateway 进程(跑 agent loop)
  │ OPENCLAW_RAW_STREAM=1 时逐事件追加
  ▼
/tmp/easel-raw-stream.jsonl(单文件,所有会话共享!)
  │ 后端本轮开始记文件尾偏移 → _tail 线程读新增行
  │ 首个新事件的 runId 闩锁本轮(:2361-2371),别会话的行被忽略
  ▼
SSE token / thinking / activity 事件 ──▶ 浏览器
  │ 同时每事件落盘 outputs/_sessions/.jsonl
  ▼
断线重连:GET /api/chat/jobs/{turn_id}/stream 重放 / /api/chat/last 取回最终结果

两个设计点展开。① 共享文件的隔离问题:所有会话的流写进同一个文件, Web 后端怎么只取自己这轮的?本轮开始时记下文件当前末尾偏移(从这之后的新行才是本轮的), 再对每行检查 runId——首个带新 runId 的事件闩锁本轮,之后只放行同一 run 的行。 闩锁(latch)是并发编程的常用原语:一旦确定归属就锁死,防后续串台。② 双写: 事件进 SSE 的同时落盘到 outputs/_sessions/——这就是断线重放的数据源 (A14 的"产物落盘"思想用于流事件)。

三、supervisor/forward 分离(:2064-2066 注释原文)

「run 跑在独立后台任务里,客户端断开只结束 forward,不取消 supervisor → openclaw 照常跑到底、结果落盘,前端断线后 /api/chat/last 取回。」

拆开讲:处理一轮对话涉及两个异步角色——supervisor(跑 openclaw 进程、等结果、 落盘)和 forward(把事件从队列推给浏览器 SSE)。关键决策:两者生命周期解耦—— 浏览器断开(用户刷新页面)只杀 forward,supervisor 和 openclaw 照跑。 为什么必须这样:一条视频制作 turn 可能 20 分钟,刷新页面不该杀掉进行中的工作 (A15 信任:用户的委托不能因为页面刷新而蒸发)。代价:必须配合事件落盘 + 重放端点, 否则断线后永远拿不到结果。

四、传输层钉子:一次会话不换边

HTTP 直连(无冷启动,实测 4.1-4.5s/轮)与 CLI spawn(7.1-7.6s/轮)两条路都通 gateway (:78-79 注释有实测数),但同一会话绝不中途换—— outputs/_sessions/<sk>.transport 钉子文件钉死选择。 换了会怎样?两条路径写不同的 transcript(HTTP 端点不读 x-openclaw-session-id 头,:1829), 换边 = 历史劈叉,静默丢失。回退(A13)必须考虑状态一致性的实锤案例: 可用性优化不能破坏数据完整性。

五、常见踩坑

坑 1:tail 从文件头开始。必须先记偏移再 tail——从头读会把别的会话的历史行当成自己的。 坑 2:断线即取消任务。supervisor/forward 不分离的产品,用户刷新一次 = 20 分钟白跑。 坑 3:共享流文件不做归属校验。多会话串台的典型来源。

六、检索练习

七、出口检验

画出自己版本的流式链路图,标注:token 从哪产生、经过哪个共享文件、runId 在哪一步闩锁、 断线后走哪两个端点恢复。讲给我听。

一手源:web/app.py:2051-2160(函数 docstring 本身就是一篇设计文档)。