OpenCode 源码拆解(六):系统怎么不崩?——韧性的 3 个设计模式

0 阅读11分钟

基于 OpenCode(sst/opencode)真实源码,拆解系统韧性的 3 个设计模式。AI Agent 系统面临各种故障:LLM API 限流、Server 崩溃、工具执行失败。OpenCode 怎么让系统在故障下依然稳定?

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

没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法。


Agent 系统的三个韧性挑战

  1. 错误信息给谁看? —— 传统错误给程序员,Agent 错误得给 LLM
  2. 事件和状态怎么一致? —— 改了数据库但事件没发出去怎么办
  3. 429 / 500 / timeout 怎么重试? —— 无脑重试会被封 IP

OpenCode 用 3 个设计模式应对:

挑战模式
错误给 LLM 看模式 1:错误即反馈(ToolFailure 类型化错误通道)
事件状态一致性模式 2:事务内 commit 钩子(事件与落库原子提交)
智能重试模式 3:按错误语义决策(语义 reason 而非裸状态码)

模式 1:错误即反馈(ToolFailure 类型化错误通道)

源码位置: packages/llm/src/tool-runtime.ts · packages/llm/src/schema/errors.ts · packages/core/src/tool/

痛点

传统错误信息是给程序员看的:Error: EACCES。但在 Agent 系统里,错误信息的读者是 LLM——它需要理解错误、推断原因、制定修复策略。如果工具一出错就 throw 异常,整个 Agent 循环就崩了,LLM 没有机会自我修复。

真实机制:不 throw,走 Effect 的失败通道

OpenCode 不用 throw,而是定义一个结构化的错误类型 ToolFailure。工具执行出错时,构造一个 ToolFailure,由运行时统一转成给 LLM 的 type: "error" 结果——错误被吸收成了正常返回值,而不是炸掉整个流。

// packages/llm/src/schema/errors.ts · 第 194-207 行(错误类型定义)
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()(
  "LLM.ToolFailure",
  {
    message: Schema.String,                                       // 面向 LLM 的自然语言描述
    error: Schema.optional(Schema.Defect()),                      // 原始异常(可选)
    metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
  },
) {}
​
// 源码注释原文:
// Handlers must map their internal errors to this shape; the runtime catches
// ToolFailure's and surfaces them as tool-error events plus a tool-result of
// type "error" so the model can self-correct.
// Anything thrown or yielded that is NOT a ToolFailure is treated as a defect
// and fails the stream.

真正的「转 return」在运行时分发函数里——工具不存在或没有 execute 处理器时,直接返回 error 结果,而不是抛异常:

// packages/llm/src/tool-runtime.ts · 第 23-35 行(运行时分发)
export const dispatch = (tools: Tools, call: ToolCallPart) => {
  const tool = tools[call.name]
  if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` }))
  if (!tool.execute)
    return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` }))
​
  return decodeAndExecute(tool, call).pipe(
    Effect.map((value) => result(call, value)),
    // 捕获 ToolFailure → 转成正常的 error 结果,不炸流
    Effect.catchTag("LLM.ToolFailure", (failure) =>
      Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
    ),
  )
}

真实工具把内部错误映射成 ToolFailure:

// packages/core/src/tool/bash.ts · 第 196 行
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))
​
// packages/core/src/tool/write.ts · 第 88 行
Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))

翻译:

# Python 等价(用异常类模拟类型化错误通道)
class ToolFailure(Exception):
 """工具执行失败,但能被运行时吸收成返回值"""
 def __init__(self, message: str):
     self.message = message  # 面向 LLM 的自然语言描述
​
def run_tool(name, call):
 tool = tools.get(name)
 if tool is None:
     return {"type": "error", "value": f"Unknown tool: {name}"}
 try:
     return tool.execute(call)
 except ToolFailure as failure:
     # 捕获后转成正常返回值,不向上抛
     return {"type": "error", "value": failure.message}
TS 写法含义
Schema.TaggedErrorClassEffect 的带标签错误类,类似 Python 的自定义 Exception 子类
Effect.catchTag("LLM.ToolFailure", fn)只捕获特定标签的错误。Python 的 except ToolFailure
Effect.succeed(result(...))返回一个成功值(即使内容是 error 结果)。Python 的 return result

设计约束:只有预期错误变成反馈

源码注释(packages/core/src/tool/AGENTS.md 第 28 行)明确:只把预期的、类型化的错误转成 ToolFailure,不要吞掉所有异常——因为中断(interruption)和缺陷(defect)必须原样炸出来,否则 bug 会被藏起来。

维度传统软件Agent 系统
错误给谁程序员(日志/堆栈)LLM(自然语言 message)
处理方式throw 异常,中断流程ToolFailure → 吸收成 error 结果,循环继续
恢复策略人工修复AI 看到 message 后自行修正

模式 2:事务内 commit 钩子(事件与落库原子提交)

源码位置: packages/core/src/event.ts · packages/core/src/session/context-epoch.ts

痛点

改了数据库再发事件 → 发事件时网络断了 → 数据库改了但客户端不知道。先发事件再改数据库 → 改数据库失败了 → 客户端收到了不存在的变更通知。经典的双写不一致问题。

真实机制:SQLite 单事务原子提交

术语说明: 源码里没有 "outbox" 这个命名。OpenCode 的机制是「事务内 commit 钩子」——事件落库和数据库写入在同一个 SQLite 事务里完成,而不是经典 outbox 表的后台轮询。

publish API 接受一个 commit 回调,它在事件写入之后、事务提交之前执行:

// packages/core/src/event.ts · 第 118-124 行(PublishOptions 定义)
export interface PublishOptions {
  readonly id?: ID
  readonly metadata?: Record<string, unknown>
  readonly location?: Location.Ref
  /** Local operational projection committed atomically with a new durable event. */
  readonly commit?: (seq: number) => Effect.Effect<void>
}

原子性在 commitDurableEvent 里实现——整段包在 db.transaction(..., { behavior: "immediate" }) 中,顺序是:读序列表取 latest → 投影器 → commit 回调 → 写序列表 → 写事件表:

// packages/core/src/event.ts · 第 320-348 行(核心提交逻辑)
for (const projector of list) {
  yield* projector(committed)
}
if (commit) yield* commit(seq)          // ← commit 回调与事件落库同事务
yield* db.insert(EventSequenceTable).values([{ aggregate_id: aggregateID, seq, ... }]).run()
yield* db.insert(EventTable).values([{
  id: event.id, aggregate_id: aggregateID, seq,
  type: versionedType(definition.type, durable.version), data: encoded,
}]).run()

真实调用点(context-epoch.ts 里发布上下文更新事件,并原子推进快照):

// packages/core/src/session/context-epoch.ts · 第 72-76 行
yield* events.publish(
  SessionEvent.ContextUpdated,
  { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
  { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },  // 与事件原子提交
)

翻译:

# Python 等价(用 SQLite 事务模拟)
def publish_context_updated(session_id, text, snapshot):
 with db.transaction() as tx:   # 单事务
     event_id = event_table.insert({
         "type": "Session.ContextUpdated",
         "session_id": session_id,
         "text": text,
     })
     # commit 回调:在同一个事务里推进快照
     advance(tx, session_id, snapshot)
     # 任一失败 → 整个事务回滚,事件也不会留下
 return event_id
TS 写法含义
db.transaction(..., { behavior: "immediate" })立即加写锁的 SQLite 事务。Python 的 with db.transaction()
yield* commit(seq)执行 commit 回调。Python 的 commit(seq)
Effect.orDie把错误转成致命缺陷(不该发生就直接崩)。Python 的 assert 语义

事件本身是 durable 的——存在 SQLite(EventTable / EventSequenceTable)里,进程崩溃后不丢失。重启时未发布的事件能从 SQLite 恢复。这不是「尽力而为」的发布,而是「精确一次」的保证。


模式 3:按错误语义决策(reason 分类 + Retry-After)

源码位置: packages/llm/src/route/executor.ts · packages/llm/src/schema/errors.ts

先搞清楚:HTTP 状态码和指数退避

HTTP 状态码:服务器返回的 3 位数字。401 = 未认证、403 = 禁止、429 = 限流、500 = 服务器错误、502/503 = 网关错误。

指数退避:每次失败后等待时间翻倍——第 1 次 500ms,第 2 次 1000ms——避免大量请求同时重试导致服务器雪崩。

Retry-After header:HTTP 响应头。服务器返回 429 时附带 Retry-After: 30,意思是「30 秒后再试」。遵守这个值是避免被封 IP 的关键。

真实机制:按语义 reason 而非裸状态码

OpenCode 不按裸状态码 switch,而是先把状态码映射成语义化的 reason 对象,每个 reason 自带 retryable 属性:

// packages/llm/src/route/executor.ts · 第 35-38 行(真实常量)
const MAX_RETRIES = 2          // 最多重试 2 次(不是 3 次)
const BASE_DELAY_MS = 500      // 基础延迟 500ms
const MAX_DELAY_MS = 10_000    // 最大延迟 10s

// packages/llm/src/route/executor.ts · 第 91 行(可重试状态码)
const retryableStatus = (status: number) =>
  status === 429 || status === 503 || status === 504 || status === 529

// packages/llm/src/route/executor.ts · 第 225-275 行(状态码 → 语义 reason)
if (input.status === 401) return new AuthenticationReason({ kind: "invalid", ... })
if (input.status === 403) return new AuthenticationReason({ kind: "insufficient-permissions", ... })
if (input.status === 429) {
  if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body))
    return new QuotaExceededReason({ ... })   // body 含 quota 关键词 → 不重试
  return new RateLimitReason({ retryAfterMs: input.retryAfterMs, ... })
}
if ([400, 404, 409, 413, 422].includes(input.status))
  return new InvalidRequestReason({ classification: isContextOverflow(body) ? "context-overflow" : undefined, ... })
if (input.status >= 500 || retryableStatus(input.status))
  return new ProviderInternalReason({ status: input.status, retryAfterMs: input.retryAfterMs, ... })
return new UnknownProviderReason({ status: input.status, ... })

每个 reason 的 retryable 属性决定重不重试(errors.ts):

错误类型状态码retryable原因
AuthenticationReason401 / 403falseAPI key 错了,重试没用
QuotaExceededReason429 + quota 关键词false配额用完,等也没用
RateLimitReason429(普通限流)true遵守 Retry-After 等待
InvalidRequestReason400 / 404 / 409 / 413 / 422false请求本身有问题(含 context-overflow)
ProviderInternalReason500 / 502 / 503 / 504true可能是临时故障
UnknownProviderReason其他false未知错误不盲目重试

关键纠正: 400 上下文超长(context-overflow)不重试,而是把错误返回给 LLM 去处理——compact(压缩上下文)的触发是另一套逻辑(第二章讲的 token 阈值),不是在这里直接触发。

Retry-After 严格遵守 + 指数退避带抖动

// packages/llm/src/route/executor.ts · 第 93-106 行(Retry-After 三种格式)
const retryAfterMs = (headers: Record<string, string>) => {
  const millis = Number(headers["retry-after-ms"])
  if (Number.isFinite(millis)) return Math.max(0, millis)
  const value = headers["retry-after"]
  if (!value) return undefined
  const seconds = Number(value)
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)   // "30" → 30 秒
  const date = Date.parse(value)
  if (!Number.isNaN(date)) return Math.max(0, date - Date.now())     // HTTP-date 格式
  return undefined
}

// packages/llm/src/route/executor.ts · 第 345-364 行(退避策略)
const retryDelay = (error: LLMError, attempt: number) => {
  if (error.retryAfterMs !== undefined) return Effect.succeed(Math.min(error.retryAfterMs, MAX_DELAY_MS))
  // 没有 Retry-After 时:500ms × 2^attempt,带 ±20% 抖动,封顶 10s
  return Random.nextBetween(
    Math.min(BASE_DELAY_MS * 2 ** attempt * 0.8, MAX_DELAY_MS),
    Math.min(BASE_DELAY_MS * 2 ** attempt * 1.2, MAX_DELAY_MS),
  ).pipe(Effect.map((delay) => Math.round(delay)))
}

翻译:

# Python 等价
import random, time

MAX_RETRIES = 2
BASE_DELAY_MS = 500
MAX_DELAY_MS = 10000

def retry_after_ms(headers):
 if "retry-after-ms" in headers:
     return max(0, int(headers["retry-after-ms"]))
 if "retry-after" in headers:
     return max(0, int(headers["retry-after"]) * 1000)
 return None  # 没有就走指数退避

def retry_delay(error, attempt):
 if error.retry_after_ms is not None:
     return min(error.retry_after_ms, MAX_DELAY_MS)  # Retry-After 优先
 base = BASE_DELAY_MS * (2 ** attempt)
 jitter = random.uniform(0.8, 1.2)                   # ±20% 抖动
 return min(int(base * jitter), MAX_DELAY_MS)
TS 写法含义
2 ** attempt2 的 attempt 次方。Python 的 2 ** attempt
Random.nextBetween(a, b)返回 [a, b) 间随机数。Python 的 random.uniform(a, b)
Math.min(x, MAX)取较小值封顶。Python 的 min(x, MAX)

策略核心: Retry-After 优先且被 MAX_DELAY_MS 封顶;否则 500ms × 2^attempt 带 ±20% 抖动,封顶 10s,最多重试 2 次。

不是所有错误都值得重试。好的重试策略是:知道什么时候不该重试。


三个模式怎么组合工作

当 LLM 调用遇到 API 故障或工具执行失败时,3 个模式协作:

LLM 调用 / 工具执行
│
├─ 1. 智能重试(模式 3)
│     ├─ 429 限流 → 遵守 Retry-After 精确等待 → 重试(最多 2 次)
│     ├─ 5xx 服务器错误 → 指数退避(500ms×2^n,±20% 抖动)→ 最多 2 次
│     ├─ 401/403 认证失败 → 不重试 → 报错给用户
│     └─ 400 上下文超长 → 不重试 → 错误反馈给 LLM
│
├─ 2. 如果重试也失败 / 工具执行出错
│     └─ 错误即反馈(模式 1)
│          └─ 构造 ToolFailure → 运行时吸收成 error 结果(不炸流)
│              LLM 下一轮看到 message → 自行调整策略
│
└─ 3. 如果操作成功执行(如上下文更新)
      └─ 事务内 commit 钩子(模式 2)
           ├─ 事件写入 SQLite durable 队列(同一事务)
           ├─ commit 回调执行(推进快照)
           └─ 任一失败 → 整体回滚,事件不残留
                (崩溃重启后未发布事件可恢复)

小结

模式解决的问题核心机制你熟悉的概念
错误即反馈错误信息给 LLM 看ToolFailure 类型化通道 + 运行时吸收成 error 结果Python try/except 捕获后 return
事务内 commit 钩子事件和状态一致性事件落库与 commit 回调同 SQLite 事务数据库事务 + 消息队列
按错误语义决策API 故障不雪崩reason 分类 + 遵守 Retry-After + 指数退避带抖动Circuit Breaker

这三个模式共同回答一个问题:怎么让系统在故障下依然稳定——错误能自愈、状态不丢失、重试不雪崩。


全系列总结

6 篇文章,21 个设计模式,覆盖了 OpenCode Agent 系统的 6 大核心领域:

篇问题域模式数核心设计
1进程生命周期3Worker 隔离 + 端口透明 + per-directory DI
2上下文管理5分层加载 + 增量 Reconcile + 配置瀑布 + 压缩 + 修剪
3代码编辑3LSP 闭环 + 影子 Git + 模糊匹配
4Agent 循环5三态控制 + Part 模型 + 权限即数据 + Doom Loop + 自修复
5命令执行2AST 预分析 + 纯 tail 滑动窗口
6系统韧性3错误即反馈 + 事务内 commit 钩子 + 按错误语义决策

可迁移的 Agent 设计原则

从这 21 个模式中,提炼出 7 条通用原则:

  1. 隔离是健壮性的基础 —— Worker 线程隔离 UI 和 AI,per-directory 隔离不同项目,Semaphore 隔离并发写入
  2. 增量优于全量 —— Reconcile 只发 delta,compact 只保留摘要,纯 tail 滑动窗口只留尾部 + 落盘
  3. 预期 AI 会犯错 —— 9 层模糊匹配、工具名纠正、错误即反馈,都是对 LLM 不精确的系统性容错
  4. 错误是反馈不是终止 —— ToolFailure 不炸流,让 AI 在下一轮自我修正
  5. 权限是数据不是代码 —— 配置驱动 Agent 能力,Arity 字典匹配命令前缀
  6. 锁粒度决定并发性能 —— per-gitdir 而非全局,不同项目并行同项目串行
  7. 知道什么时候不重试 —— 401/403 不重试、429 遵守 Retry-After、400 上下文超长不重试