异步 fork
AI 的'多线程思考'
一、原理:什么任务可以"在主流程之外跑"
上一章我们看了三个 hidden agent——title、summary、compaction。这些 agent 都有一个共同的"运行时问题"——它们什么时候跑、怎么跑、跟主循环什么关系?
直观的想法是——主循环跑完一个 turn 后,依次跑这些 hidden agent:
主循环结束
→ 跑 title agent
→ 跑 summary agent
→ 跑 compaction(如果需要)
→ 返回 idle 状态
这种串行模式简单——但慢。title agent 跑 2 秒、summary 跑 1 秒——加起来用户要等 3 秒才看到"对话完成"。
更糟的是——这些 hidden agent 跟用户当前任务无关。用户不在乎 title 什么时候生成——session 列表里晚 2 秒出现标题完全可以接受。但用户在乎"我能不能立刻开始下一个 turn"——他可能想紧接着追问。
所以正确的模式应该是——让 hidden agent 跟主循环并发跑:
主循环结束
→ fork title agent (后台跑)
→ fork summary agent (后台跑)
→ 返回 idle 状态(用户可立刻继续)
(同时在后台)
→ title 完成 → 发事件 → UI 更新标题
→ summary 完成 → 发事件 → UI 更新摘要
这种 fork 模式让用户体验顺滑——主循环一结束就 idle,hidden agent 在后台默默跑完。
但 fork 不只是"异步执行"那么简单。它需要解决几个工程问题:
1. 资源管理——fork 出去的任务什么时候结束?如果 session 被删了,正在跑的 hidden agent 怎么取消?
2. 错误隔离——hidden agent 失败了不能影响主流程。title 生成失败用户不应该看到错误——session 没标题就没标题,下次再生成。
3. 状态同步——fork 的任务完成后怎么通知主流程?怎么更新数据库?
4. 并发安全——两个 hidden agent 同时跑会不会相互影响?比如 title 和 summary 同时读 session 状态。
5. 顺序约束——某些 hidden agent 之间有依赖(compaction 必须在 main turn 完成后跑),怎么表达这种依赖?
这些问题在传统 web 服务里通过 background job queue(Redis、RabbitMQ)解决。但在 AI Agent 里——每个 session 自己跑自己的——更适合用 functional programming 的 fork 模式。
这就是 Effect FP 的用武之地。
二、案例:opencode 的 Effect FP fork 模式
opencode 用 Effect 4(一个 TypeScript 的函数式编程库)实现 fork。Effect 提供几种 fork 模式,opencode 选择性地用了其中三种。
模式 1:Effect.forkIn(scope)
最常见的模式。把一个 effect fork 到指定的 scope——当 scope 关闭时,fork 出去的 effect 自动被中断和清理。
opencode 用这种模式跑 title 和 summary:
// session/prompt.ts 大概第 1187 行
yield* sessions.ensureTitle(sessionID).pipe(
Effect.ignore,
Effect.forkIn(scope),
)
// 大概第 1304 行
yield* summary.summarize(sessionID).pipe(
Effect.ignore,
Effect.forkIn(scope),
)
注意几个细节:
细节 1:Effect.ignore——title 或 summary 失败了直接忽略错误。不会让主流程感知。这是错误隔离。
细节 2:Effect.forkIn(scope)——fork 到 session 的 scope。当 session 结束(用户删除、退出、abort)时,正在跑的 hidden agent 自动取消。这是资源管理。
细节 3:放在 step === 1 时——只在第一个 turn 完成后 fork title 和 summary。后续 turn 不重复 fork。这是顺序约束。
这种 fork 模式让 hidden agent 跟主循环完全解耦——主循环不等它们、它们也不阻塞主循环。
模式 2:Effect.forkScoped
类似 forkIn 但 scope 是当前 effect 的 scope,而不是显式传入的。常用于"resource-bound 的并行任务"——比如打开一个文件流、fork 一个监听器、文件关闭时自动停止监听。
opencode 用这种模式跑文件监听、event listener 这类"跟资源生命周期绑定"的任务。
模式 3:Effect.forkChild
跑一个"子任务"——但子任务的生命周期跟父任务绑。父任务结束子任务也结束、父任务失败子任务也取消。
opencode 用这种模式跑 tool execution——每个 tool call 是一个 forked child,跟 turn 的生命周期绑定。turn 被 abort 时,所有正在跑的 tool 立刻取消。
这三种 fork 模式叠加,opencode 实现了精细化的并发控制——不同任务用不同的 fork 模式,不同的生命周期、不同的错误处理、不同的资源边界。
我们看一个具体的并发场景——一次完整 turn 完成后:
Time 0:00.000 Main turn completes (assistant message done)
Time 0:00.001 Fork title agent (forkIn session scope)
Time 0:00.002 Fork summary diff compute (forkIn session scope)
Time 0:00.003 Main loop returns idle (用户可立刻下一个 turn)
Time 0:00.030 Summary completes (diff 算法很快)
→ 发 SummaryUpdated event
→ UI 更新摘要
Time 0:00.150 Title agent completes (LLM 调用)
→ 发 TitleUpdated event
→ UI 更新标题
Time 0:30.000 User starts next turn (这时 hidden agent 早就完事了)
如果是串行执行——用户要等 0.15 秒才能下一个 turn。150 毫秒听起来不长,但用户能感知到。fork 模式下,主循环只等 3 毫秒就 idle——用户感觉"瞬间响应"。
这种"瞬间响应"是 AI 产品丝滑感的重要来源——后台在做事,前台立刻准备好下一步。
三、设计启示:什么任务该 fork、什么该串行
这一章的核心论点:fork 不是"为了并行而并行"——是为了让用户感知到的延迟最小化。
什么任务该 fork?满足下面任一条件:
条件 1:不影响用户当前任务。Title 生成是不是不影响——可以晚 0.5 秒。summary 生成是不是不影响——也可以晚。compaction 是不是不影响——影响。所以前两个该 fork,compaction 要么阻塞当前 turn(保证下个 turn 有空间)要么 fork(但要小心 race condition)。
条件 2:失败用户不会感知。Title 失败用户没事——下次 turn 再生成就好。Tool 调用失败用户会感知——这个不能 fork。
条件 3:任务时长不可预测。如果 LLM 调用突然慢——title 可能 5 秒才完成。如果串行等它,用户体验崩。fork 就没问题——5 秒后 UI 自动更新。
什么任务不该 fork?
条件 A:用户在等的任务。当前 turn 的 LLM 调用、tool 执行——用户在屏幕前看着。fork 出去等于消失——用户不知道发生了什么。
条件 B:影响下一步的任务。compaction 必须在下一个 turn 开始前完成——下个 turn 需要被压缩后的 context。这种"前置依赖"的任务必须串行(或者用 promise/await 显式等待)。
条件 C:有状态依赖的任务。如果任务 A 修改 session 状态、任务 B 也修改——fork 出去同时跑会有 race condition。这种要么加锁、要么串行。
设计 AI 产品时关于 fork 你要做的几件事:
1. 明确每个 hidden task 的"fork 性"。问自己:用户当前任务需要这个 task 的结果吗?如果不需要 → fork。如果需要 → 串行或者用 promise/await。
2. fork 出去的 task 要 fail-safe。失败时 swallow 错误、记日志、不影响主流程。Effect.ignore 是这个模式的典型实现。
3. 给 fork 一个 scope。fork 不能"逃逸"——必须绑定到某个生命周期。session 级别的 fork 跟 session 一起结束、turn 级别的 fork 跟 turn 一起结束。这避免了"幽灵 fiber"(用户已经 session over 了但 hidden agent 还在跑)。
4. fork 后用事件通知前端。fork 出去的 task 完成后通过事件总线告诉 UI"更新这一块"。不要让 UI 轮询——浪费。
5. 避免 fork 过深。fork 的 task 又 fork 子 task 又 fork 孙 task——会让生命周期管理变复杂。一般 2 层就够(main fork hidden agent,hidden agent 内部串行不再 fork)。
6. 给 fork 上 timeout。fork 出去的 task 万一卡死永远不返回——会消耗资源。给一个合理 timeout(title agent 30 秒、summary 5 秒)——超时就杀掉。
7. 监控 fork 的"完成率"。跑过的 fork 有多少正常完成、多少失败、多少 timeout?如果失败率高(>5%),说明 hidden agent 有 bug 需要修。
最后一个观察。Fork 是 functional programming(特别是 Effect / ZIO 这类系统)带来的红利。传统命令式代码里实现这种"细粒度并发控制 + 资源管理 + 错误隔离"非常痛苦——要手动写 cancellation token、要小心处理 race condition、要管理 thread pool。
Effect 把这些都抽象成了"fork + scope + ensuring"几个组合子。代码变得声明式而非命令式——你说"这个 task fork 到这个 scope",剩下的 Effect runtime 帮你处理。
这是 opencode 选择 Effect 的核心理由之一——AI Agent 里有大量需要 fork 的场景(hidden agents、tool execution、stream consumption、event listening),命令式代码会让这部分非常复杂。Effect 让它们可控。
下一章 2.7 我们看 compaction 决策里一个特别有意思的细节——为什么 PRUNE_PROTECT 只保护 Skill 不保护 Tool 输出。这是个内容分类的产品判断。