详情

首页手游攻略 OpenCode 源码拆解(六):系统怎么不崩怎么做-位置线索

OpenCode 源码拆解(六):系统怎么不崩怎么做-位置线索

佚名 2026-09-03 09:34:55

OpenCode 源码拆解(六):系统怎么不崩怎么做-位置线索需要先看清适用场景和关键步骤,避免只记结论却忽略实际限制。

Agent 系统的三个韧性挑战

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

放到具体任务里,先把使用场景分清楚,再按功能选择做法,能少走不少弯路。很多人接触OpenCode时只停留在基础操作,真正影响效率的往往是Agent 系统的三个韧性挑战、模式 1:错误即反馈(ToolFailure 类型化错误通道)、痛点这些细节。

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/

痛点容易忽略的点

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

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

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

按操作场景看,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/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;

按操作场景看,tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` })) if (!// packages/llm/src/tool-runtime.ts · 第 23-35 行(运行时分发)export const dispatch = (tools: Tools, call: ToolCallPart) => { const tool = tools[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:

按操作场景看,否则 bug 会被藏起来。// 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}` }))设计约束:只有预期错误变成反馈源码注释(packages/core/src/tool/AGENTS.md 第 28 行)明确:只把预期的、类型化的错误转成 ToolFailure不要吞掉所有异常——因为中断(interruption)和缺陷(defect)必须原样炸出来

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

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

更直接地说,源码位置: packages/core/src/event.ts · packages/core/src/session/context-epoch.ts

痛点容易忽略的点

更直接地说,改了数据库再发事件 → 发事件时网络断了 → 数据库改了但客户端不知道。先发事件再改数据库 → 改数据库失败了 → 客户端收到了不存在的变更通知。按操作场景看,经典的双写不一致问题。

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

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

更直接地说,: 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 · 第 118-124 行(PublishOptions 定义)export interface PublishOptions { readonly id?: ID readonly metadata?

更直接地说,type: versionedType(definition.typedurable.version)data: encoded}]).run()真实调用点(context-epoch.ts 里发布上下文更新事件并原子推进快照):// 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: aggregateIDseq... }]).run()yield* db.insert(EventTable).values([{ id: event.idaggregate_id: aggregateIDseq

放到具体任务里,重启时未发布的事件能从 SQLite 恢复。这不是「尽力而为」的发布,而是「精确一次」的保证。// 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) }, // 与事件原子提交)事件本身是 durable 的——存在 SQLite(EventTable / EventSequenceTable)里,进程崩溃后不丢失。

模式 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——避免大量请求同时重试导致服务器雪崩。

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

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

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

按操作场景看,"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):// packages/llm/src/route/executor.ts · 第 35-38 行(真实常量)const MAX_RETRIES = 2// 最多重试 2 次(不是 3 次)const BASE_DELAY_MS = 500// 基础延迟 500msconst 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) ?

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

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

先要分清的是,否则 500ms × 2^attempt 带 ±20% 抖动,封顶 10s,最多重试 2 次。// 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 undefinedconst 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% 抖动,封顶 10sreturn 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)))}策略核心: Retry-After 优先且被 MAX_DELAY_MS 封顶;

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

三个模式怎么组合工作容易忽略的点

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

实际使用时,±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这三个模式共同回答一个问题:怎么让系统在故障下依然稳定——错误能自愈、状态不丢失、重试不雪崩。LLM 调用 / 工具执行│├─ 1. 智能重试(模式 3)│ ├─ 429 限流 → 遵守 Retry-After 精确等待 → 重试(最多 2 次)│ ├─ 5xx 服务器错误 → 指数退避(500ms×2^n

全系列总结

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 上下文超长不重试
点击查看更多
推荐专题
热门阅读