45 min · 精读 web/app.py:2051-2470(/api/chat/stream 全流程)
🎒 预备知识:完成上一课「L09」(零基础入口:第 0 课)
理想很直接:模型吐一个 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:浏览器原生的事件推送通道,服务端可单向持续推送)推浏览器。
用文件当进程间的流管道——看起来土,实则稳:进程崩溃文件还在(可重放)、
多读者天然支持、零新依赖。
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 的"产物落盘"思想用于流事件)。
「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 本身就是一篇设计文档)。