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

基于 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" 结果------错误被吸收成了正常返回值,而不是炸掉整个流

scala 复制代码
// 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 结果,而不是抛异常:

less 复制代码
// 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

javascript 复制代码
// 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 复制代码
# 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.TaggedErrorClass Effect 的带标签错误类,类似 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 回调,它在事件写入之后、事务提交之前执行:

typescript 复制代码
// 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 回调 → 写序列表 → 写事件表:

scss 复制代码
// 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 里发布上下文更新事件,并原子推进快照):

php 复制代码
// 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 复制代码
# 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 属性:

typescript 复制代码
// 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 原因
AuthenticationReason 401 / 403 false API key 错了,重试没用
QuotaExceededReason 429 + quota 关键词 false 配额用完,等也没用
RateLimitReason 429(普通限流) true 遵守 Retry-After 等待
InvalidRequestReason 400 / 404 / 409 / 413 / 422 false 请求本身有问题(含 context-overflow)
ProviderInternalReason 500 / 502 / 503 / 504 true 可能是临时故障
UnknownProviderReason 其他 false 未知错误不盲目重试

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

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

typescript 复制代码
// 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 复制代码
# 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 ** attempt 2 的 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 个模式协作:

sql 复制代码
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 进程生命周期 3 Worker 隔离 + 端口透明 + per-directory DI
2 上下文管理 5 分层加载 + 增量 Reconcile + 配置瀑布 + 压缩 + 修剪
3 代码编辑 3 LSP 闭环 + 影子 Git + 模糊匹配
4 Agent 循环 5 三态控制 + Part 模型 + 权限即数据 + Doom Loop + 自修复
5 命令执行 2 AST 预分析 + 纯 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 上下文超长不重试

相关推荐
用户847181054192 小时前
langchain.js中stream()的单模式 vs 多模式梳理
agent
阿里云云原生5 小时前
告别功能堆叠,迎接“对话即服务”:FinXScope AI 原生六层架构设计详解
agent
昵称好难啊7 小时前
ClaudeCode: 怎么规划任务
llm·agent
京东云开发者7 小时前
ICML 2026|NaviAgent:面向 Oxygen 智能体的可扩展工具编排
agent·ai编程
玉鸯7 小时前
让 Agent 面向用户:AG-UI 协议构建 Agent 前端
前端·python·agent
文心快码BaiduComate7 小时前
不限额度!「文心快码测试版」限免体验来了
agent·ai编程·文心快码
人生百态,人生如梦7 小时前
每日论文解读 (8.3) 1——DeepResearch Agent System:稀疏激活架构驱动的自主深度研究Agent
架构·llm·agent·deepresearch
牧艺8 小时前
别让 Agent 猜需求:前端用「一页 Spec」把返工砍掉一半
人工智能·agent·vibecoding
ClouGence9 小时前
无需 API 配置,DeepSeek-V4-Flash 正式版落地使用指南
agent·ai编程·deepseek