OpenCode 源码拆解(一):Agent 怎么活下来?——进程生命周期的 3 个设计模式

5 阅读14分钟

基于 OpenCode(sst/opencode)真实 clone 下来的 TypeScript 源码,逐个拆解设计模式的具体实现。本文是系列第一篇,覆盖「进程生命周期」领域的 3 个模式。

源码地址:github.com/sst/opencode · TypeScript monorepo · Effect 框架

没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法,不需要提前学语法。


为什么 Agent 需要特殊的进程架构?

如果你写过一个简单的 AI 对话脚本,它大概长这样:

while True:
    user_input = input("You: ")
    response = llm.chat(user_input)
    print(f"AI: {response}")

这能跑,但只适合 demo。真正的 Agent 面临一个致命问题:LLM 调用又慢又贵

一次复杂的工具调用链(读 5 个文件 → 分析 → 写 3 个文件 → 跑测试)可能耗时 5-10 分钟。如果这个过程跑在前端进程里,SSH 断了就全丢了。更关键的是:AI 在跑任务时,TUI 界面不能卡死——用户得能看到实时进度。

OpenCode 的 Server 架构解决三个层级的问题:

层级问题模式
任务不丢 + UI 不卡AI 跑长任务时 UI 要响应,任务状态要可观测模式 1:进程内隔离 + 事件驱动
多项目共存同时打开多个项目目录,端口不冲突模式 2:端口透明 + 按需暴露
代码怎么组织几十个模块,每个项目独立实例,切换不串模式 3:per-directory 依赖注入

下面逐一拆解。


模式 1:进程内隔离 + 事件驱动通信

源码位置: packages/opencode/src/cli/cmd/tui.ts · packages/opencode/src/cli/tui/worker.ts · packages/opencode/src/server/server.ts

出乎意料的事实

你可能以为 OpenCode 跑了两个进程:一个 TUI 客户端,一个 Daemon 服务端。但默认模式不是这样。

默认模式下,TUI 主进程启动一个 Worker 线程,Server 代码跑在 Worker 里。两者通过 postMessage RPC 通信,不走真正的 HTTP。URL 是假的 http://opencode.internal——只是一个标识符,没有真正的网络请求。

为什么用 Worker 而不是直接跑?

如果 AI 逻辑和 TUI 渲染在同一个线程里,LLM 的长时间计算会阻塞 UI 渲染——界面完全卡死。Worker 线程把计算移到另一个线程,主线程只负责渲染。

// packages/opencode/src/cli/cmd/tui.ts
const worker = new Worker(file, { env: ... })
const client = Rpc.client<typeof rpc>(worker)       // postMessage RPC
const transport = {
  url: "http://opencode.internal",                   // 虚拟 URL,不发起真正的网络请求
  fetch: createWorkerFetch(client),                  // fetch 通过 RPC 代理到 Worker 内的 Server
  events: createEventSource(client),                 // 事件通过 RPC emit 转发
}

翻译:

# Python 等价:用 multiprocessing 或 threading
import multiprocessing
​
def run_tui():
 worker = multiprocessing.Process(target=run_server)  # 对应 new Worker()
 worker.start()
 # 主进程通过 Queue/Pipe 和子进程通信(对应 postMessage RPC)
 # url 是假的,真正的通信走进程间消息
 transport_url = "http://opencode.internal"  # 只是标识符

关键语法:

TS 写法含义
new Worker(file, { env })启动一个 Worker 线程,执行 file 指定的代码。Python 类似 multiprocessing.Process(target=file)
Rpc.client(rpc)(worker)基于 Worker 创建一个 RPC 客户端。Python 类似通过 multiprocessing.Queue 包装出的远程调用代理
createWorkerFetch(client)fetch(url) 拦截,转发成 RPC 消息发给 Worker 内的 Server。相当于「假的 HTTP 层,真正走进程间通信」

外部模式:真正启动 HTTP Server

当你传 --port 参数时,才走真正的 HTTP:

// packages/opencode/src/cli/tui/worker.ts
const external = hasArg("--port") || hasArg("--hostname")
if (external) {
  server = await Server.listen({ port, hostname, mdns })
  return { url: server.url.toString() }  // 真正的 HTTP URL
}

这个模式下,Server 是独立的 HTTP 进程,TUI 通过真正的网络请求连接。适合远程访问(比如从手机查看进度)。

任务完成通知:SSE 推送 + 轮询兜底

Worker 内的 Server 跑完一个长任务后,怎么通知 TUI?用 SSE(Server-Sent Events) 推送事件流:

TUI 主进程  ←──SSE 事件流──  Worker 内的 Server
                              ↓
                    session.status 变为 "idle" 时 = 任务完成

但 SSE 有可能丢事件。OpenCode 加了一道保险——同时轮询

// packages/opencode/src/cli/cmd/run/stream.transport.ts
// 主通道:SSE 事件流
const stream = await sdk.global.event()  // GET /global/event// 兜底通道:每 250ms 轮询一次状态
// 注释原文:"some transports may lose status events but still deliver message events"
setInterval(() => sdk.session.status(), 250)

翻译:

# Python 等价
# 主通道:监听事件流(像监听一个消息队列)
for event in server.event_stream():
 handle(event)
​
# 兜底:每 250ms 主动查一次状态
# 万一 SSE 丢了 "任务完成" 事件,轮询还能发现 session.status == "idle"
while True:
 status = server.get_status()
 if status == "idle":
     on_task_complete()
     break
 time.sleep(0.25)

SSE 断线重连:指数退避 + Last-Event-ID

网络模式下 SSE 断了怎么办?SDK 客户端内置了自动重连:

// packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts
while (true) {
  // 记录 lastEventId,重连时通过 Last-Event-ID 头恢复
  const backoff = Math.min(retryDelay * 2 ** (attempt - 1), 30000)
  await sleep(backoff)
  attempt += 1
}
# Python 等价:指数退避
attempt = 1
while True:
 try:
     stream = connect_sse(url, headers={"Last-Event-ID": last_event_id})
     for event in stream:
         last_event_id = event.id  # 记住位置
         handle(event)
 except ConnectionError:
     backoff = min(3000 * 2 ** (attempt - 1), 30000)  # 3s → 6s → 12s → ... → 30s 封顶
     time.sleep(backoff / 1000)
     attempt += 1

起始 3 秒,每次翻倍,30 秒封顶。重连时带上 Last-Event-ID 头,Server 就知道从哪里续传。Python 开发者可以理解为:断点续传的事件队列

怎么停:两阶段关闭握手

讲完了「怎么启动」和「怎么通信」,最后一个关键问题:Server 怎么关闭?

一个长运行 Server 管理着 HTTP 连接、WebSocket、LSP 客户端等资源。关闭时有两种需求:

  • 优雅关闭:等正在处理的请求完成,再关闭(不丢失用户正在进行的操作)
  • 强制关闭:立刻断开所有连接,马上退出(用户要紧急退出,或 Worker 要被回收)

OpenCode 用一个 close?: boolean 参数控制两种模式。但精妙之处不在于参数本身,而在于两个 flag 的两阶段握手——保证「无论谁先发起关闭,强制模式一定能杀掉所有连接」:

// packages/opencode/src/server/server.ts · serverLayer()
const server = createServer()
const serverRef = { closeStarted: false, forceStop: false }  // 两个 flag
const close = server.close.bind(server)  // 保存原始的 close 方法// 第一步:猴子补丁(monkey-patch)server.close
server.close = ((callback) => {
  serverRef.closeStarted = true           // 标记「关闭已启动」
  const result = close(callback)          // 调用原始的 close
  if (serverRef.forceStop) {              // 如果此时 forceStop 已经被设为 true
    server.closeAllConnections()          // 立刻杀掉所有 TCP 连接
  }
  return result
}) as typeof server.close// 第二步:ListenerServerService 的 closeAll 方法
ListenerServerService.of({
  closeAll: Effect.sync(() => {
    serverRef.forceStop = true            // 标记「需要强制关闭」
    if (serverRef.closeStarted) {         // 如果此时 close 已经启动了
      server.closeAllConnections()        // 立刻杀掉所有连接
    }
  }),
})

翻译:

# Python 等价:两阶段握手
class ServerShutdown:
 def __init__(self):
     self.close_started = False   # 「close() 被调用过了吗?」
     self.force_stop = False      # 「有人要求强制关闭吗?」# 路径 A:Effect 的 Scope 关闭时触发(优雅模式)
 def graceful_close(self):
     self.close_started = True
     server.close()  # 等连接自然结束
     if self.force_stop:          # 检查:有人要求过强制吗?
         server.kill_all_connections()  # 如果有,补一刀# 路径 B:listener.stop(close=True) 时触发(强制模式)
 def force_close_all(self):
     self.force_stop = True
     if self.close_started:       # 检查:close 已经开始了吗?
         server.kill_all_connections()  # 如果开始了,立刻杀
     # 如果 close 还没开始,不急——等它开始时自己会检查 force_stop

为什么需要两阶段握手? 因为关闭操作有两个触发源:

  1. Effect 的 Scope 关闭(优雅路径)——当 Worker 线程退出或 serve 命令结束时,Scope 自动调用 server.close()
  2. 用户的 stop(true) 调用(强制路径)——当用户要紧急退出时

这两个操作可能以任意顺序发生。如果只有一个 flag,比如只检查 forceStop

  • 用户先调 stop(true)(设 forceStop=true),但此时 server.close() 还没被调用——连接杀不掉
  • 等 Scope 关闭调用 server.close() 时,才知道要强制杀——但此时可能已经等了一段时间

两阶段握手机制确保:无论谁先执行,只要两个条件都满足过,closeAllConnections() 一定会被调用。 这在 Python 里相当于一个双线程安全的「一次性触发器」。

makeStop 把这整套逻辑包成了一个可缓存(Effect.cached)的关闭函数——多次调用 stop() 不会重复执行清理:

// packages/opencode/src/server/server.ts · makeStop()
function makeStop(state, unpublishMdns, listenerUrl) {
  return Effect.gen(function* () {
    // cached 保证只执行一次
    const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
    const closeScopeOnce = yield* Effect.cached(
      Scope.close(state.scope, Exit.void).pipe(Effect.ignore, Effect.ensuring(...))
    )
    // 返回真正的 stop 函数
    return (close?: boolean) =>
      Effect.gen(function* () {
        yield* unpublishMdns        // 1. 先取消 mDNS 广播
        if (close) yield* forceCloseOnce  // 2. 如果强制模式,杀连接
        yield* closeScopeOnce        // 3. 关闭 Scope(触发上面的两阶段握手)
      })
  })
}

理解: Effect.cached(X) 的效果是「第一次执行 X,之后调用直接返回缓存结果」。Python 类似 functools.lru_cache 装饰一个无参函数——第一次真正计算,之后直接拿结果。这里用来保证 forceCloseOncecloseScopeOnce 无论被调多少次,底层只执行一次。


模式 2:端口透明 + 按需暴露

源码位置: packages/opencode/src/server/server.ts · startWithPortFallback · setupMdns

出乎意料的事实

你可能以为端口选定后会存文件或写环境变量,客户端读文件来发现端口。但 OpenCode 不持久化端口。

默认模式(Worker 线程)下,端口完全对用户透明——Worker 内部拿到端口,主线程通过 RPC 直连,根本不需要知道端口号。只有 --port 外部模式才暴露端口,且由用户显式指定。

端口回退:4096 优先,失败则随机

当走外部模式时,OpenCode 先试默认端口 4096,被占了就让操作系统分配随机端口:

function startWithPortFallback(opts: ListenOptions) {
  if (opts.port !== 0) return startListener(opts, opts.port)
  // Match the legacy listener port-resolution behavior: explicit `0` prefers
  // 4096 first, then any free port.
  return startListener(opts, 4096).pipe(
    Effect.catch(() => startListener(opts, 0))
  )
}

Python 等价:

def start_with_port_fallback(opts):
 if opts.port != 0:
     return start_listener(opts, opts.port)
 try:
     return start_listener(opts, 4096)    # 先试默认端口
 except PortInUseError:
     return start_listener(opts, 0)       # 失败了,让 OS 分配随机端口

port = 0 传给 http.Server.listen(0) 时,操作系统会分配一个可用端口。这是 OS 行为,不是 OpenCode 的逻辑。

为什么是 4096? 源码注释写的是 Match the legacy listener port-resolution behavior——为了兼容旧版本的行为。4096 是一个约定俗成的端口(不在 Well-Known 0-1023 范围内,也不在常见服务的 Registered 1024-49151 冲突热点区),选它纯粹是历史惯例。

mDNS:只广播,供外部设备发现

mDNS 解决的是「端口选定后,局域网内其他设备怎么找到我」。OpenCode 用 bonjour-service 库广播自己:

// packages/opencode/src/server/mdns.ts
bonjour.publish({
  name: `opencode-${port}`,
  type: "http",
  host,
  port,
  txt: { path: "/" }
})

翻译:

# Python 等价
from zeroconf import Zeroconf, ServiceInfo
​
info = ServiceInfo(
 type_="_http._tcp.local.",
 name=f"opencode-{port}._http._tcp.local.",
 addresses=[socket.inet_aton(host)],
 port=port,
)
zeroconf.register_service(info)  # 局域网广播

但注意:OpenCode 代码库内没有消费 mDNS 的逻辑。消费方是外部设备——比如你用手机浏览器打开 opencode.local:端口。mDNS 是给手机用的,不是给自己的客户端用的。

源码里还有一个细节:mDNS 只在非 loopback 地址时才广播:

const publish =
  opts.mdns && port &&
  opts.hostname !== "127.0.0.1" &&
  opts.hostname !== "localhost" &&
  opts.hostname !== "::1"
if (!publish && opts.mdns) {
  yield* Effect.logWarning("mDNS enabled but hostname is loopback; skipping mDNS publish")
}

也就是说,如果你绑定的是 localhost,即使传了 --mdns 参数也不会广播——因为局域网其他设备根本访问不到你。

精妙之处:默认不需要端口

回到整体设计——OpenCode 的默认模式根本不暴露端口(Worker 线程直接 RPC)。端口回退和 mDNS 只在用户主动选择外部模式时才启用。这比「先选端口再连」简单得多:

模式端口可见性连接方式适用场景
默认(Worker)完全透明进程内 RPC本地开发
--port 外部用户指定或回退HTTP + SSE远程访问、手机查看
attach <url>手动传入HTTP + SSE连接已运行的远程 Server

模式 3:per-directory 依赖注入(InstanceStore 模式)

源码位置: packages/opencode/src/project/instance-store.ts · packages/opencode/src/effect/instance-state.ts

标准依赖注入的问题

你可能见过 Spring 风格的 DI:全局一个容器,所有 Service 注册在里面。但 OpenCode 有个特殊需求——用户可能同时打开多个项目目录

如果只有一个全局容器,项目 A 的 Snapshot 服务和项目 B 的会共享同一个实例。但它们操作的 Git 仓库不同、配置不同、LSP 客户端不同。必须每个目录独立一套实例。

InstanceStore:以目录为 key 的实例缓存

OpenCode 的解法是 InstanceStore——一个以项目目录路径为 key 的缓存 Map:

// packages/opencode/src/project/instance-store.ts
// 简化版核心逻辑
const entries = new Map<string, Entry>()  // key = 目录路径function load(directory: string) {
  if (entries.has(directory)) {
    return entries.get(directory).result  // 已有实例,直接复用
  }
  // 新目录:boot 一个全新的实例上下文
  const entry = { result: Deferred(), ctx: null }
  entries.set(directory, entry)
  // fork 一个 boot 流程,构建该目录专属的 Service 实例树
  Effect.runFork(boot(directory, entry))
  return entry.result
}

翻译:

# Python 等价
class InstanceStore:
 def __init__(self):
     self._cache = {}  # key: 目录路径 → value: 该目录的 Service 实例集合def load(self, directory: str):
     if directory in self._cache:
         return self._cache[directory]  # 同一目录复用实例
     # 新目录:创建全新的 Service 实例树
     ctx = InstanceContext(directory=directory, worktree=..., project=...)
     services = self._boot(ctx)  # 构建 Snapshot、LSP、Config... 全部新建
     self._cache[directory] = services
     return services

InstanceContext:每个目录的身份标识

每个项目目录有独立的上下文:

export interface InstanceContext {
  directory: string    // 项目工作目录(如 /home/user/my-project)
  worktree: string     // git worktree 根
  project: Project.Info
}

切换项目 = 销毁 + 重建

切换项目目录时,不是「修改现有实例」,而是彻底销毁旧实例 + 创建全新实例

// reload 逻辑(简化)
function reload(directory: string) {
  // 1. 等旧实例完成当前操作
  await entries.get(directory).result
  // 2. 销毁旧实例的全部资源(关闭 LSP、释放 Git 锁、清理文件监听器)
  runDisposers(directory)
  // 3. 广播销毁事件
  emit("server.instance.disposed")
  // 4. 重新 boot 新实例
  boot(directory)
}

翻译:

# Python 等价
def reload(self, directory: str):
 old = self._cache[directory]
 # 销毁旧实例的全部资源
 for disposer in old.disposers:
     disposer()  # 关闭 LSP 客户端、释放 Git 锁、停止文件监听...
 # 重新构建
 ctx = InstanceContext(directory=directory, ...)
 self._cache[directory] = self._boot(ctx)

ScopedCache:per-directory 资源隔离的实现

InstanceStore 底层用 Effect 的 ScopedCache 实现资源隔离。每个目录的实例集有自己的 Scope——目录销毁时,该 Scope 关闭,里面所有资源(LSP 连接、Git 进程、文件监听器)自动逆序清理。

// packages/opencode/src/effect/instance-state.ts
export interface InstanceState<A> {
  readonly cache: ScopedCache<string, A>  // key = directory
}

理解: ScopedCache<string, A> 就是「以字符串为 key 的缓存,每个 key 对应一个独立的资源 Scope」。你可以理解为 Python 的 dict[str, ExitStack]——每个 key 有自己的资源清理栈,删掉 key 时自动关闭该栈。

精妙之处:全局单例 + per-directory 上下文分离

OpenCode 的 DI 系统分两层:

管理者生命周期类比
全局服务Layer / LayerNode进程级单例Java Spring 的 @Singleton
目录级服务InstanceStore + ScopedCache切换目录时销毁重建Spring 的 @RequestScope(但以目录而非 HTTP 请求为 scope)

全局服务(如 InstanceStore 本身、全局 Config)整个进程只有一份。目录级服务(如 Snapshot、LSP Client、Git)每个项目目录独立。这比标准 Spring DI 多了「按业务上下文隔离实例」的能力。


三个模式怎么组合工作

讲完了三个独立模式,最后看它们怎么在一次真实的 opencode 启动中串联起来。

当你在终端敲下 opencode 时,发生的完整流程:

用户运行 opencode
│
├─ 1. TUI 主进程启动
│     └─ 判断:有 --port 参数吗?
│        │
│        ├─ 没有(默认模式):
│        │    └─ 创建 Worker 线程 ──────────────────── 模式 1
│        │         ├─ Worker 内调用 Server.listen()
│        │         │    └─ 端口对主线程透明(RPC 直连)── 模式 2
│        │         └─ 主线程通过 fake URL 通信
│        │
│        └─ 有 --port(外部模式):
│             └─ Worker 内 Server.listen({ port })
│                  ├─ 端口回退: 4096 → 随机 ──────────── 模式 2
│                  └─ mDNS 广播(非 loopback 时)──────── 模式 2
│
├─ 2. Server 启动后,加载当前目录
│     └─ InstanceStore.load(当前目录) ──────────────── 模式 3
│          ├─ 首次加载:boot 全新实例树(LSP/Git/Config...)
│          └─ 已缓存:直接复用
│
├─ 3. 运行中
│     ├─ 用户切换项目目录 → InstanceStore.reload() ──── 模式 3
│     └─ LLM 跑长任务 → SSE 推送 + 250ms 轮询兜底 ──── 模式 1
│
└─ 4. 关闭
      └─ 调用 stop(close?)
           ├─ close=false: Scope 关闭 → 两阶段握手优雅关闭 ── 模式 1
           └─ close=true:  forceStop + closeAllConnections 强制杀 ── 模式 1

关键洞察:三个模式不是平行关系,而是嵌套关系。 模式 2(端口策略)是模式 1(进程隔离)在「外部模式」下的一个子行为;模式 3(实例隔离)发生在模式 1 的 Worker 线程内部。它们共同构成一次完整的 Agent 进程生命周期。


小结

模式解决的问题核心机制你熟悉的概念
进程内隔离 + 事件驱动长任务不卡 UI + 任务状态可观测Worker 线程 + SSE 推送 + 轮询兜底 + 两阶段关闭Python multiprocessing + 消息队列
端口透明 + 按需暴露多项目端口冲突 + 远程访问默认不走 HTTP,--port 时才回退进程内直接调用 vs HTTP 微服务
per-directory 依赖注入多项目实例隔离InstanceStore + ScopedCacheSpring @RequestScope 以目录为 scope

这三个模式共同回答一个问题:一个 Agent 进程怎么同时服务多个项目、保持 UI 响应、且切换不串。 这不是简单的「前后端分离」,而是从线程隔离到端口策略到实例隔离的一整套基础设施。