青雲的博客

Article

Orca 聊久了开始卡:DeepSeek 流式输出到底慢在哪

· 14 分钟阅读

我先怪了上下文,后来发现不是它

用户最先给出的反馈很主观:同一个对话聊得越久,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

我先没改上下文窗口

我先检查了上下文计算和请求构造,没有马上改模型配置。这里有两个容易混在一起的概念:

  1. 模型允许多大的上下文,是模型路由和产品契约。
  2. 已经拿到一段历史以后,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重新扫描或探查 JSONLcommit 确认成本跟日志长度绑定
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,把相邻的 MessageDeltaReasoningDelta 合并到最多 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)
ledgercommit 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 次数。

没有这条时间线,我只能确认系统不再对每个增量重复做全量工作,不能确认不同机器和模型上的体感变化。

我现在会先查哪四件事

长对话卡顿很像“上下文太长”。现在我会先问四个问题:

  1. 模型还没有返回,还是进程已经拿到 delta 但没有及时投影?
  2. 每个 delta 是否都触发了提交、索引或全量复制?
  3. renderer 是否把每个 token 都变成了一次唤醒和绘制?
  4. 优化后的批处理、缓存和 lookahead 是否仍然尊重 generation、终态和错误边界?

源码和测试现在支持一个判断:卡顿的根因不是历史长度本身,而是每个增量都重复携带了历史工作。用户端到底快了多少,还要等端到端 trace。

Keep Reading

相关文章

评论