Skip to content

feat: Agent 流式输出匀速打字机泵(回答逐字顺滑流出) - #273

Merged
taekchef merged 5 commits into
mainfrom
codex/streaming-typewriter-pump
Aug 20, 2026
Merged

feat: Agent 流式输出匀速打字机泵(回答逐字顺滑流出)#273
taekchef merged 5 commits into
mainfrom
codex/streaming-typewriter-pump

Conversation

@taekchef

@taekchef taekchef commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

问题

回答随网络节奏一块一块蹦出:一次来一大坨就整坨出现,网络停一下画面就僵住,不是真正的逐字流式。

方案

真实文本照常累积(latestAgentStreamingText 及落盘/完成/中断/复制路径完全不动);新增 AgentStreamingDisplayPump 只给显示端(agentStreaming.text)配速:

  • 33ms 一拍(30Hz,与原发布门同频;JS 管线每次消费为全文重解析,30Hz 是不掉帧的设计上限);
  • 连续速率配速(c17d2a6):每拍 1 字起步,速率随落后量线性爬升(每落后 10 字 +1 字/拍),上限 12 字/拍;小数速率由 pacingCredit 攒零补整,渲染成均匀字符流,消除整数步长跳变(初版「≤4 字吐 1 字/落后多吐 ⅓」在阈值处步长会跳变);
  • 完成/中断/新请求 → 立即停泵;完成后显示由 finishStreamingMarkdown(全文) 接管,无泵延迟;
  • 聊天不可见时泵暂停(与原「不可见不发布」一致),重新可见后经追赶路径快速补齐;
  • 首帧语义保持:首字吐出前 agentStreaming.text 为空,思考指示器不提前消失;start() 同步走一拍,首字延迟与旧行为一致。

改动

  • 新增 Sources/WeiBei/Stores/AgentStreamingDisplayPump.swift(泵本体,~90 行,节奏参数为命名常量);
  • WorkspaceStore.swift 仅生命周期接线:泵声明 + 发布点替换(agentStreaming.text = textpump.start())+ 3 处清理挂 stopAndReset();33ms 门与可视化块更新保持事件驱动不变;
  • 新增 Tests/WeiBeiSafetyTests/AgentStreamingDisplayPumpTests.swift(7 个确定性用例)。

共享核心文件占用

  • 占用文件: Sources/WeiBei/Stores/WorkspaceStore.swift
  • 负责人任务: 本分支(codex/streaming-typewriter-pump),净增 12 行纯接线,不迁移不重构
  • 预计释放条件: 本 PR 合并即释放
  • 冻结门禁:WorkspaceStore.swift 为零增长冻结文件,本次净增 12 行接线(逻辑全部在新文件),使用 growth-exempt 豁免:[growth-exempt: 匀速打字机泵对 WorkspaceStore 的 12 行生命周期接线(1 处声明 + 3 处清理 + 1 处发布点替换),全部逻辑位于新文件 AgentStreamingDisplayPump.swift,无可避免地需要触碰请求生命周期点]

验证

  • swift build
  • swift run WeiBeiSelfCheck ✅ / swift run WeiBeiDev selfcheck-assertions ✅ / nslog-scan
  • swift run WeiBeiWebEditorCheck ✅(编辑器/JS 侧零改动回归,流式前缀推送无感)
  • 泵确定性逻辑 13 项本地离线驱动全过(真实源文件编译驱动; XCTest 用例将在 CI data_safety 步骤执行)
  • 真实 App 冒烟(待验收):中文回答逐字匀速流出;长回答/网络突发短暂加速追赶不僵住;完成瞬间直接全文;中断立即停;极短回答完整显示(fix: 流式渲染器在首 token 到达前启动(消除开头字号突变与首字延迟) #272 回归)

未做

  • 增量 append 推送协议 / 流式插件 insert 模式 / 60Hz:探查确认属后续可选升级(消除单次消费的全文成本后 60Hz 才有意义),不混入本任务;
  • 节奏参数(33ms / 4 字 / ÷3)为命名常量,验收手感不适再调。

追加:流式视觉平滑(dbedcde)与 CI 阻塞修复(dd70e51)

  • dbedcde 新增 streaming-appearance.ts:匀速泵解决节奏后,字符仍瞬时出现(「吐字感」)。新增编辑器装饰插件:新插入字符 180ms 淡入 + 末尾呼吸光标(插入停止 400ms 消失;完成后 buffer 置空即重绘装饰,不派发事务)。纯装饰、不加事务;顶层整块插入不套装饰(已被块完整性断言约束);prefers-reduced-motion 关闭。编辑器产物随提交重建。
  • dd70e51 测试稳定性修复(与本任务无关,为解除 CI 阻塞一并提交):NoteEditingSessionTests.testConcurrentSnapshotRequestsShareOneFlight 依赖 async let 子任务在一次 Task.yield() 内启动,新工具链无此保证——本分支两次 CI 失败(命令数 0 / 第二趟飞行超时),main 全量检查通过(run 32412530544)证实为概率性抖动。已确定性重写(同步直测合并 + continuation 注册先于挂起的轮询等待)。

网络按块到达时回答一坨坨蹦出加停顿。新增 AgentStreamingDisplayPump:
真实文本照常累积(latestAgentStreamingText 不动),显示端以 33ms
一拍追赶——落后 ≤4 字每拍吐 1 字(摊平 token 突发),落后多时每拍
吐 1/3(几何加速),完成/中断/新请求立即停泵,显示由完成路径的
finishStreamingMarkdown(全文) 接管,无泵延迟。

- WorkspaceStore 仅做生命周期接线(声明 + 3 处清理 + 发布点替换);
  33ms 门与可视化块更新保持事件驱动不变;
- agentStreaming.text 为纯显示通道,落盘/完成判定/中断/复制不受影响;
  聊天不可见时泵暂停,重新可见后经追赶路径快速补齐;
- 首帧语义保持:泵吐出首字前 agentStreaming.text 为空,思考指示器
  不提前消失;start() 同步走一拍,首字延迟与旧行为一致。

WorkspaceStore.swift 为冻结文件,净增 11 行接线,PR 打 growth-exempt
豁免(理由见 PR 描述)。
@taekchef taekchef added the growth-exempt 维护者批准冻结核心文件超过 50 行的净增长 label Aug 20, 2026
原先「落后≤4 字吐 1 字、落后多吐 1/3」在落后量越过阈值时步长在
1 与 2-4 字之间跳变,节奏感有波动。改为连续速率律 + 小数累积:

- 每拍 1 字起步,速率随落后量线性爬升(每落后 10 字 +1 字/拍),
  上限 12 字/拍(约 360 字/秒,大积压快速泄放不瞬dump);
- 小数速率(如 2.5 字/拍)由 pacingCredit 攒零补整,渲染成均匀
  字符流;credit 上限 1,长时间贴近目标不会积攒突发;
- 稳态验证:落后 10 字整(≈60 字/秒输入)恰好每拍 2 字完全匀速;
  突发积压按 2,1,1,2… 或 2×n→1 平滑衰减回打字速度。
匀速泵解决了节奏,但每个字仍是瞬时出现,离散感仍在。新增
streaming-appearance 装饰插件,给编辑器侧补视觉过渡:

- 新插入字符(≤24 字的文本块内插入)带 180ms 淡入,30Hz 的
  离散插入被连续动画抹平,观感为连续流动;
- 流式期间末尾跟随安静光标(2px,呼吸式闪烁),插入停止 400ms
  或流式结束后消失;完成后 buffer 置空即重绘一次装饰,
  不派发事务、不影响自检的事务/序列化计数;
- 纯装饰:不加事务、不碰文档内容;spec 对象共享,未变化的
  淡入区间跨 tick 复用 DOM,动画不重启;
- 顶层整块插入(新段落)不套淡入/光标(内联装饰必须留在
  文本块内,否则渲染为额外顶层元素,已被 WeiBeiWebEditorCheck
  的块完整性断言捕获);prefers-reduced-motion 时全部关闭。

CSS 在 index.html(手维护源);编辑器产物已重建入库。
testConcurrentSnapshotRequestsShareOneFlight 依赖 async let 子任务在
一次 Task.yield() 内启动——新工具链无此保证:两次 CI 失败分别为
「子任务均未启动(命令数 0)」与「第二个子任务晚于快照送达启动、
第二趟飞行无人喂而超时」。main 全量检查能通过说明是概率性抖动
(run 32412530544),但本分支连中两次。

改为:同步路径直测「两请求合并一趟飞行、一次送达双双唤醒」
(零调度依赖);新增异步包装的确定性用例——continuation 在子任务
首次挂起前同步注册,轮询命令出现即代表已挂上飞行,再送达断言。

与打字机泵改动无关的测试稳定性修复,为解除本 PR CI 门禁阻塞
一并提交,已在 PR 描述记录。
用户反馈「帧数感觉有点低」:淡入动画与逐拍插入突发在 WKWebView
同一条主线程上竞争——每拍 O(全文) 的规范化两遍、正文正则装饰
扫描把插入对齐的动画帧挤掉,眼睛看到掉帧。

- 编辑器:updateStreamingMarkdown 增加干净尾部快路径——新尾部
  不含 $ < \\ ` \r 等规范化触发字符且与 raw 锚点重合时直接
  splice,跳过全文 normalize 与 withFrontmatter 序列化;一旦某拍
  改写了文本即清锚回全量,finish 强制全量对账;
- 装饰:流式期间跳过五组正文正则扫描(完成渲染全量重建),
  mermaid/代码块装饰保持;
- 淡入 180ms → 260ms:典型中文速率下约 8 字同时溶解,
  33ms 到达间隙被连续淡入彻底桥接;
- 泵:节拍改为绝对 deadline 锚定,消除 MainActor 拥塞下的累计漂移。
@taekchef
taekchef marked this pull request as ready for review August 20, 2026 21:16
@taekchef
taekchef merged commit 6ed09ea into main Aug 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

growth-exempt 维护者批准冻结核心文件超过 50 行的净增长

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant