Article
Orca 聊久了开始卡:DeepSeek 流式输出到底慢在哪
我先怪了上下文,后来发现不是它
用户最先给出的反馈很主观:同一个对话聊得越久,TUI 越卡。
短对话里,模型返回一个 delta,屏幕很快就会多出一段文字。历史变长以后,首个可见增量变晚,连续输出也开始断断续续。用户看到的是“模型没有及时回复”,但这句话没有告诉我们时间花在哪里。
我最先看的是上下文窗口。这个方向不完全错,长对话确实会让请求变大。但当前 Orca 的 DeepSeek 路由仍然允许 1,000,000 token 的上下文。把窗口改小,只能绕开一部分工作,解释不了为什么进程已经收到一个小 delta,屏幕还要等很久。
于是我把一次增量从网络到屏幕的路径拆开:
DeepSeek HTTP stream
-> provider step
-> runtime surface projection / commit
-> session ledger / history projection
-> TUI inbox / render frame
每一段都可能制造延迟,历史变长还会放大其中一些固定成本。我把问题改写成一句更容易验证的话:收到一个增量以后,系统为了让它上屏,重复做了多少工作?
实际修改集中在提交 210c6e099,现在已经进入 Orca 的 main。
我先没改上下文窗口
我先检查了上下文计算和请求构造,没有马上改模型配置。这里有两个容易混在一起的概念:
- 模型允许多大的上下文,是模型路由和产品契约。
- 已经拿到一段历史以后,runtime 是否每次都重新计数、复制和归一化,是本地热路径。
症状更像第二个问题,所以 max_context_tokens 保持 1,000,000 不变。我沿着重复工作继续查:
- 同一段长文本被反复 token count;
- 没有必要修改的 conversation message 被重复 clone;
- 每个小 delta 都触发一轮提交和投影;
- commit identity 查询随着 JSONL 历史重复探查;
- TUI 为每个增量唤醒 renderer。
在没有时间线和 profile 之前,先改配置只是改变实验条件。配置可能有影响,但它不能替代定位。
一个 delta 到屏幕,要经过四个地方
我把路径上的固定成本列在一起:
| 位置 | 每个增量可能做的工作 | 历史变长后的后果 |
|---|---|---|
| provider | 生成一次语义提交,等待一次确认 | 小 delta 数量越多,提交和锁竞争越密集 |
| runtime | 复制完整 surface,重新归约历史 | 局部投影退化成全量工作 |
| ledger | 重新扫描或探查 JSONL | commit 确认成本跟日志长度绑定 |
| renderer | 唤醒、重建 markdown、绘制一帧 | 每秒 draw 次数接近 token 数量 |
后面的排查就按这个顺序走:provider 提交,runtime 投影,持久化查找,renderer 绘制。
provider 先攒起来,再交给 runtime
原来的 provider 路径把小步骤逐个交给 runtime surface ingress。一个 delta 看不出问题,连续输出几千个小 delta 时,每一步都带着通知、投影、提交和后续投影。
crates/orca-runtime/src/provider_turn.rs 现在用 ProviderStepBatcher 收集 provider step:
- 最多 32 个 step;
- 最长等待 16 ms;
- 达到任一条件就 flush;
- provider turn 结束时 flush 剩余步骤。
const PROVIDER_STEP_BATCH_LIMIT: usize = 32;
const PROVIDER_STEP_BATCH_MAX_DELAY: Duration = Duration::from_millis(16);
这两个数字不是 benchmark 得出的结论。32 用来限制单批数据量,16 ms 用来限制等待时间。后面有真实 p95/p99 数据,再调整它们。
provider 到 runtime 的语义 ingress 改成非阻塞的有序投递,不再为每个增量等待确认。提交和终态仍由 runtime 负责,provider 只把有序批次交给 owner。
吞吐提升不能吞掉错误。队列关闭、runtime 退出或提交失败仍要进入可观察的错误路径;只有过时 generation 的取消,才按 stale cancellation 处理,避免旧任务污染新任务的状态。
批内投影不再回头复制历史
批次进入 runtime 后,要把 provider step 转成 surface event。真正要省下的是每个 delta 对完整 surface state 的复制。
crates/orca-runtime/src/runtime_actor/generation_context.rs 为一批步骤建立局部 offset overlay。每个 stream 在批内维护自己的下一个 byte offset,最后一次性生成事件。批内的顺序由局部状态推进,完整历史只在需要提交时参与。
这里有个顺序约束容易被性能目标盖过去:先验证 generation、operation fence 和 turn identity,再消费 stream redaction prefix。过时 batch 即使最后会被拒绝,也不能先改内部 redaction 状态。
局部缓存、批内 offset 和 lookahead 都是新的暂存状态,必须继续服从原来的身份校验和生命周期边界。
reducer 只复制改到的 shard
surface reducer 还维护 applied history,用来识别重复提交和恢复后的历史事实。原来的整体 copy-on-write map 在修改一个 key 时,容易让大范围状态参与复制。
SurfaceReducerState 现在把 applied history 分成 64 个 COW shard:
const APPLIED_HISTORY_SHARD_COUNT: usize = 64;
一次提交只修改命中的 shard,其他 shard 继续共享 Arc。snapshot clone 的复制范围因此缩小,提交成本也更接近本次变化。
跨 shard 的 any_key 仍然要遍历 shard,热点 key 是否均匀也还没有 profile。COW shard 只解决复制放大,不负责所有查询。
commit identity 先建索引
surface ledger 需要回答一个问题:这个 commit 是否已经存在?如果每次都从 JSONL 重新探查,短日志时不明显,长会话里就会让增量提交不断背上历史成本。
JsonlSurfaceCommitLedger 现在在加载时建立一次 SurfaceCommitIndex:
ordered保留提交顺序,用于恢复和诊断;by_id直接定位 commit;- probe 先查内存索引,再校验 digest、event count 和 cursor;
- identity conflict 仍然返回错误。
索引只优化查找,不改变 JSONL 的 durable 语义。持久化成功、重复提交、摘要冲突和恢复游标仍然由 ledger 确认。
索引把一次性工作放到加载阶段,热路径只做窄查询。代价是加载时多占一点内存;原始顺序仍然保留,恢复和诊断不受影响。
token count 和 message clone 都设上限
crates/orca-provider/src/context.rs 增加了有界 token count cache:最多 4096 个条目,按 map 和 FIFO 中的两份 key 估算约 8 MiB 上限,短文本不进入缓存。
缓存只解决重复计数,不负责上下文压缩。全局 mutex 可能在高并发时竞争;如果 profile 证明它是热点,下一步应该按 provider 或线程拆分,而不是继续扩大 cache。
conversation lowering 也调整为只有在确实需要 tool normalization 时才复制消息。普通路径保留已有消息引用,减少长历史下不必要的 clone。
model profile 没改,1,000,000 token 也没有被换成更小的硬编码。改动只针对同一上下文上的重复工作。
TUI 不用为每个 token 画一帧
runtime 事件已经可以批量生成,如果 TUI 仍然对每个小 delta 唤醒 renderer,用户还是会感觉卡顿。
crates/orca-tui/src/renderer_runtime_inbox.rs 增加了 RendererRuntimeInboxOwner,把相邻的 MessageDelta 或 ReasoningDelta 合并到最多 64 KiB:
- 只合并同一 channel 的相邻增量;
- 遇到 tool、approval、task、terminal 等事件立即停止;
- iterator 被提前 drop 时,lookahead event 放回 owner buffer;
- shutdown 由 owner 统一释放 receiver。
合并只作用于绘制成本。工具调用、权限响应、任务状态和终态事件仍然保持原来的边界和顺序。
合并器先定义不可跨越的 barrier。否则几个相邻字符串虽然拼对了,用户操作、工具结果和终态顺序却可能变得不可观察。
改完以后,增量走这条路
DeepSeek delta
-> provider 收集到 32 个或等待 16 ms
-> runtime 非阻塞接收有序 batch
-> generation context 用局部 offset 投影
-> reducer 只复制命中的 COW shard
-> ledger 通过内存索引确认 commit identity
-> TUI 合并相邻文字 delta,再触发绘制
每层只处理自己拥有的状态:
| 层 | 负责 | 不负责 |
|---|---|---|
| provider | 收集并发送有序 step batch | 决定终态是否成立 |
| runtime ingress | 接收、背压和错误传播 | 绕过 generation fence |
| generation projection | 批内 offset 和事件生成 | 修改上下文窗口契约 |
| reducer | 应用事件、维护 applied history | 保证所有查询 O(1) |
| ledger | commit identity、digest 和 cursor | 放宽一致性检查 |
| context | 有界计数缓存、减少 clone | 让大窗口自动变快 |
| TUI | 合并绘制增量、保留 barrier | 合并工具和终态事件 |
修改涉及 13 个文件,主要变化都在这些所有权边界上。缓存没有被塞到最上层,TUI 也不需要理解 runtime 的提交事实。
测试能说到哪里
我跑了这些检查:
cargo fmt --all -- --check通过;cargo check --workspace通过;cargo test -p orca-provider --lib:179/179 通过;cargo test -p orca-tui --lib renderer_runtime_inbox:5/5 通过;- generation projection 相关测试:2/2 通过;
- runtime surface commit 集成测试:38/38 通过;
- runtime surface interaction 集成测试:35/35 通过。
完整 runtime suite 并行运行时,有两条网络权限测试受到本机权限影响,单独重跑通过。这个结果说明测试环境存在并行敏感的网络权限边界,不能写成代码回归,也不能当作真实网络的生产证明。
这些用例覆盖了批处理 flush 边界、generation fence、stale cancellation、commit identity、TUI lookahead 和 token cache,已有 runtime contract 没有被改坏。
它们没有测真实 DeepSeek 请求在不同历史长度下的 p50、p95 和 p99。固定模型、prompt、工具返回和会话长度的端到端数据还没有补上,所以现在不能写性能提升比例。
还缺一条端到端时间线
下一轮需要给每个 generation 记录同一组时间点:
provider_chunk_received
provider_batch_flushed
surface_projection_done
surface_commit_accepted
renderer_batch_drained
frame_presented
再用固定 fixture 测 1k、10k、50k、100k token 历史下的:
- 首个可见增量延迟;
- 稳态 token 到 frame 的间隔;
- 每轮 surface commit 数;
- reducer clone 字节数和 ledger probe 时间;
- TUI 每秒 draw 次数。
没有这条时间线,我只能确认系统不再对每个增量重复做全量工作,不能确认不同机器和模型上的体感变化。
我现在会先查哪四件事
长对话卡顿很像“上下文太长”。现在我会先问四个问题:
- 模型还没有返回,还是进程已经拿到 delta 但没有及时投影?
- 每个 delta 是否都触发了提交、索引或全量复制?
- renderer 是否把每个 token 都变成了一次唤醒和绘制?
- 优化后的批处理、缓存和 lookahead 是否仍然尊重 generation、终态和错误边界?
源码和测试现在支持一个判断:卡顿的根因不是历史长度本身,而是每个增量都重复携带了历史工作。用户端到底快了多少,还要等端到端 trace。
Keep Reading