基于 OpenCode(sst/opencode)真实源码,拆解系统韧性的 3 个设计模式。AI Agent 系统面临各种故障:LLM API 限流、Server 崩溃、工具执行失败。OpenCode 怎么让系统在故障下依然稳定?
源码地址:github.com/sst/opencode · TypeScript monorepo · Effect 框架
没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法。
Agent 系统的三个韧性挑战
- 错误信息给谁看? ------ 传统错误给程序员,Agent 错误得给 LLM
- 事件和状态怎么一致? ------ 改了数据库但事件没发出去怎么办
- 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.TaggedErrorClassEffect 的带标签错误类,类似 Python 的自定义 Exception 子类 Effect.catchTag("LLM.ToolFailure", fn)只捕获特定标签的错误。Python 的 except ToolFailureEffect.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 ** attempt2 的 attempt 次方。Python 的 2 ** attemptRandom.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 条通用原则:
- 隔离是健壮性的基础 ------ Worker 线程隔离 UI 和 AI,per-directory 隔离不同项目,Semaphore 隔离并发写入
- 增量优于全量 ------ Reconcile 只发 delta,compact 只保留摘要,纯 tail 滑动窗口只留尾部 + 落盘
- 预期 AI 会犯错 ------ 9 层模糊匹配、工具名纠正、错误即反馈,都是对 LLM 不精确的系统性容错
- 错误是反馈不是终止 ------ ToolFailure 不炸流,让 AI 在下一轮自我修正
- 权限是数据不是代码 ------ 配置驱动 Agent 能力,Arity 字典匹配命令前缀
- 锁粒度决定并发性能 ------ per-gitdir 而非全局,不同项目并行同项目串行
- 知道什么时候不重试 ------ 401/403 不重试、429 遵守 Retry-After、400 上下文超长不重试