青雲的博客

Article

把 @ 文件搜索推到百万路径:Orca 的一次流式重构

· 18 分钟阅读

前言

这次改 Orca,起点是一个很小的交互:在输入框里敲 @,搜索工作区文件。

小仓库里,它一直没什么问题。第一次查询构建索引,后面从缓存里做模糊匹配,热路径甚至只有几十微秒。真正的卡顿只出现在冷启动和缓存过期的时候:git ls-files、目录遍历、索引重建和整份候选评分,都可能落进用户正在打字的那条路径。

这种问题很容易修成「把重建丢到后台线程」。旧缓存先返回,后台更新完成以后,下一个按键自然拿到新结果。代码不多,体验也能覆盖大多数仓库。

我最开始确实把方案分成了三档:后台刷新、完成事件通知、完整流式搜索。只看当前问题,第一档已经够用。

但最后我还是选了最重的那条路。

原因不是想把一个补全框做复杂,而是我不想让 Orca 的 @ 搜索继续停在「小仓库里没问题」这个边界。Coding Agent 真正开始参与大项目以后,几十万甚至上百万路径并不罕见。到那个规模,缓存只是延后问题,不能回答几个更基本的工程问题:扫描还没结束时能不能先出结果?用户连续输入时旧查询会不会排队?离开 @ 以后后台线程由谁停止?旧 session 的结果会不会覆盖新 token?一百万条路径会不会同时存在两份 catalog?

这次一共改了 27 个文件,新增 3775 行,删除 526 行。原来 mentions.rs 里的同步索引,被拆成了一个独立的 orca-file-search crate,再通过 TUI 事件流接回输入框。

代码在这个提交里:

a74ef2c8 feat: add streaming mention file search

旧实现快在缓存里,慢在输入路径上

原来的链路不难理解。

非空 @query 第一次出现时,Orca 会用两次 git ls-files,或者在非 Git 场景下用 ignore walk 收集工作区路径。候选数量最多保留 10 万条,然后把路径列表缓存起来。

后续查询会克隆这份路径快照,再同步做模糊评分。

用户输入 @query
  -> 检查五秒缓存
  -> git ls-files / ignore walk
  -> 建立最多十万条路径的索引
  -> 克隆候选列表
  -> 同步评分和排序
  -> 返回 TUI

只要缓存是热的,后半段很快。所以单看平均延迟,很容易觉得没有重构必要。

但交互系统更怕尾延迟。用户不关心前十次按键是不是 50 微秒,只会记住某一次敲下 @ 后输入框突然停了一下。

旧实现还有一个更隐蔽的问题:文件发现、缓存新鲜度、模糊匹配、结果展示和 TUI 生命周期全挤在同一个模块里。它没有真正的 search session,也没有明确的 worker 所有权。想把其中一步异步化,很快就会碰到旧结果、重复线程、cwd 切换和退出清理。

问题不在某次 git ls-files 慢了几十毫秒——而是输入路径承担了不该属于它的工作。

我先去看了 Claude Code 和 Codex

动手前,我读了本地 Claude Code 和 Codex 的文件搜索实现。

两边都比「每次输入重新扫描」成熟,但走的是两条不同路线。

Claude Code 更像一个做得很完整的缓存索引系统。Git 仓库里先跑 git ls-files 拿 tracked 文件,untracked 文件在后台补充;失败或非 Git 场景再回退到 ripgrep。索引会按大约 4ms 的时间片分块构建,没建完时已经完成的 prefix 也能被查询。UI 侧有 50ms debounce,mount 时还会主动 prewarm。

它的优势是产品体验完整:.git/index mtime 可以及时发现 tracked 变化,五秒刷新兜住 untracked 文件,~/./ 和绝对路径又走独立 path completion,甚至允许配置自定义 file suggestion command。

Codex 的方向更接近我想要的上限。它用 ignore::WalkBuilder 并行遍历目录,发现一个路径就注入常驻的 nucleo::Nucleo。walker 还在扫描时,matcher 已经可以输出结果。用户继续输入时,不需要重建索引,只更新 pattern;如果 query 是在旧 query 后面追加字符,就走 reparse(..., append=true) 的增量路径。

维度Claude CodeCodex
文件发现Git/RG 先收集路径,再渐进构建索引并行 walker 边扫描边注入 matcher
第一次查询预热或查询已完成的索引 prefix扫描过程中持续输出 snapshot
每键更新50ms debounce,查询 ready catalog复用常驻 Nucleo,append 增量 reparse
产品入口路径补全、自定义 command、资源 typeahead多 root、exclude、app-server session、统一 mention

最后 Orca 采用了 Codex 这一类内核,但没有原样照搬。

我更在意几个 Codex 当前实现里没有完全收紧的地方:work channel 是 unbounded,session Drop 只发 shutdown,没有保存并 join worker handle;walker 会递归跟随 symlink;空 @ 不浏览目录;session 清掉以后 catalog 也立即消失。

所以这次不是「把 Codex 的 file search 搬过来」,而是用同一类流式架构,把 Orca 自己的生命周期、安全和百万路径验收补完整。

整个设计围绕 search session 展开

新实现里,一个 TUI 最多拥有一个 mention search session。

这个 session 绑定当前 workspace root 和当前光标所在的 @token。它拥有 walker、Nucleo matcher、查询状态、最新 snapshot、取消标记和所有 worker handle。

flowchart LR
  A[Composer 当前 @token] --> B[MentionSearchManager]
  B --> C[SearchSession]
  C --> D[Parallel ignore walker]
  D --> E[Nucleo injector]
  C --> F[Latest query slot]
  F --> G[Nucleo pattern reparse]
  E --> H[Matcher tick]
  G --> H
  H --> I[Latest snapshot slot]
  I --> J[TuiEvent MentionSearchDirty]
  J --> K[Popup projection]

目录扫描和用户查询是两条独立变化的流。walker 负责不断扩大 catalog,query 负责改变 matcher 的 pattern。两边任何一边变化,matcher 都可以产生新的 top 12 snapshot。

这让第一次查询不再等待完整扫描。只要 walker 找到一部分路径,弹窗就可以先显示部分结果,并告诉用户当前已经扫描了多少条。

@ 后面什么都没有时,也不再只显示空白提示。Orca 会异步浏览 workspace 根目录,同时开始预热完整 catalog;query 以 / 结尾时,改成渐进读取对应目录的直接子项。目录再大,也不会先把所有 entry 收集完再排序,而是每批最多处理 256 条或 5ms,只保留当前最好的 12 条。

所以 browse 和 fuzzy search 不是两套索引。它们共享同一个 session,用户从 @ 输入到 @src/,再继续模糊查一个深层文件,不需要反复销毁 catalog。

用户打得越快,队列越不能忠实

异步搜索最容易犯的错,是把每次按键都排进 channel。

用户连续输入 mmamaimain 时,中间三个 query 已经没有价值。如果 worker 还忠实地逐个计算,它处理得越认真,最终结果反而越晚。

Orca 的 query 使用 bounded latest-wins slot。新 query 会直接覆盖尚未处理的旧 query。Nucleo wakeup 也会合并,最多只保留一个待处理通知。

snapshot 同样不是排队发送。worker 只替换共享的 latest snapshot;只有从「没有待消费 snapshot」变成「有 snapshot」时,才往现有 TUI event channel 发一个很轻的 dirty event。

query:     只保留最新值
wakeup:    最多一个待处理
snapshot:  只保留最新投影
TUI event: 只负责叫醒,不搬运结果队列

搜索建议不是日志。日志要求一条不丢,补全只关心现在。

如果不先分清这两类数据,异步化很容易只是把输入线程的卡顿搬到后台队列里。

过期结果不能靠一个 query 字符串挡住

流式结果的另一个麻烦,是旧结果回来的时候,界面可能已经不是原来的界面了。

用户可能把 @main 改成了 @src,也可能把光标移到前面另一个 @token,甚至已经切换 cwd。只比较 snapshot 里的 query 字符串并不够,因为两个不同位置的 token 完全可能拥有同一个 query。

这次用了三层 guard:

  • session generation:旧 session 或旧 root 的输出直接失效;
  • 当前 token identity 和 query:只有光标正在控制的那个 token 可以收结果;
  • popup pending query:弹窗只接受自己仍然在等待的查询。

任何一层不匹配,snapshot 都不会进入可见状态。

选择状态也单独处理。结果流式更新时,排序可能不断变化。如果每个 snapshot 都把 selected index 重置到 0,用户刚按下方向键,选中项就会跳走。

所以默认状态仍然跟随第一名;一旦用户手工移动,选择就按 path 锚定。新 snapshot 里还有这条路径,就继续选它;路径消失了,再退到最接近的旧 index。

这种细节不会出现在 benchmark 里,却直接决定「流式」到底是更顺,还是更晃。

谁拥有线程

这次我给 search worker 定了一个比较硬的约束:不允许 detached thread。

SearchSession 必须持有 worker handle。取消以后,walker 和 matcher 至少每 10ms 或每处理 256 条路径检查一次状态;可见 generation 立即失效,晚到的输出先从语义上被切断。

TUI 主线程不能为了 join 卡住,所以退出或切换 root 时,会由 reaper 在线程外完成 join。新 catalog 也不会在旧 catalog 完全退出前启动,避免一百万路径场景里短时间同时存在两套大索引。

symlink 也在这里收紧。Orca 不递归跟随 symlink directory;一个 symlink 只有在 canonical target 仍然位于 workspace 内部时,才有资格成为候选。这样搜索不会因为仓库里的一个链接,意外走进整个 home directory 或外部挂载盘。

这些和模糊匹配算法没关系。但一个长期运行的 Agent TUI,出事故的经常就是这些:任务已经取消,线程还活着;cwd 已经变了,旧结果又回来;用户退出了,后台还在扫描;一个链接把搜索边界带出了工作区。

一百万条路径把很多想当然打碎了

我给第一版实现定了一个明确边界:单 workspace 最多验证一百万条 eligible path,不允许靠截断 catalog 通过测试。

性能门槛包括:首个进度 snapshot p95 不超过 100ms;warm catalog 追加字符 p95 不超过 50ms;任意修改后的完整 reparse p95 不超过 150ms;完成态增量 RSS 不超过 512MiB;构建峰值不超过 768MiB;snapshot 发布不超过 60Hz。

这个 benchmark 不是在磁盘上创建一百万个真实文件,而是向同一个 SearchSession 注入一百万条确定性的 synthetic relative path。它主要验证 catalog、matcher、查询、snapshot 和内存边界;真实文件系统的 ignore、symlink、browse 和取消行为由另外的临时目录测试覆盖。

我在当前 macOS aarch64 / 12 CPU 环境重新跑了一次 release benchmark:

指标结果门槛
catalog 路径数1,000,0001,000,000
首个进度 snapshot0.17ms≤ 100ms
append query p9546.60ms≤ 50ms
任意 reparse p95100.95ms≤ 150ms
完成态增量 RSS238,518,272 bytes≤ 512MiB
峰值增量 RSS238,714,880 bytes≤ 768MiB
snapshot 发布频率47.62Hz≤ 60Hz

优化过程里有一个很具体的变化。

最开始 matcher worker 上限是两个。一百万路径下,两线程一直过不了 query latency;把完整结果排序改成 bounded top-12 selection 以后仍然不够。三线程单次能过,但放到 25 个样本的 p95 里还是贴线。最后把 matcher 上限改成四个,才得到稳定余量。

这也是性能设计不能只写「使用并行搜索」的原因。到底几个 worker、为什么是这个上限、内存会不会跟着翻倍、p95 是否稳定——这些都得落到可执行门槛里。

我没有顺手解决所有问题

这次重构以后,Orca 的 @ 文件搜索内核已经和 Codex 属于同一类架构,在几个边界上甚至更完整:空 @ 和尾 / browse、30 秒 warm catalog、bounded latest-wins、三层 stale guard、path selection anchor、workspace-safe symlink、owned cancellation 和百万路径硬门槛。

但它还不是一个比 Claude Code、Codex 更完整的 mention 产品。

Claude Code 的启动预热、五秒 untracked freshness、自定义 suggestion command,以及 ~/、绝对路径补全仍然更成熟。Codex 还有多 root、exclude 配置、app-server search session,以及 files、skills、plugins、apps 统一在一起的 atomic mention binding。

Orca 当前仍然是单 workspace root;orca-file-search 虽然已经独立成可复用 crate,但还只接入 TUI;文件变化也采用 snapshot discovery,而不是 filesystem watcher。同一个 token 活跃期间,新建、删除和重命名不保证实时反映,untracked 和非 Git 变化最迟会在 30 秒 warm-idle 过期后得到一次新 walk。

这些边界我选择写进 ADR,而不是用一句「实时搜索」盖过去。

最后

@ 文件补全只有 12 行结果,生命周期可能就几秒。

但它需要的全部东西——session、generation、取消、背压、状态投影、失败语义、资源上限、所有权——和 Agent runtime 没区别。

做完这次重构,我确定了几件事:用户正在输入时,文件扫描、模糊匹配和线程回收都不应该停在输入路径上等同步完成。异步结果回来时,系统必须知道它还属不属于当前这个 token。session 结束时,系统必须真的把自己收干净。

每天触发几十次的小交互,边界做没做扎实,用户不需要 benchmark 也能感觉到。

Keep Reading

相关文章

评论