这是《Agent全栈开发实战》的第 8 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆解 harness 设计。
前面几篇我们把 harness 的「内核」讲完了------心脏、手脚、眼睛、记忆。这篇开始聊韧性 :你的 Agent 跑在真实世界里,外面是三家格式各异的 API、会限流会抖动的网络、会返回空字符串会被截断的模型。这一篇就讲 catbuddy 怎么在这片混乱里活下去------一套接口兜住三家差异、主模型挂了自动切、单次调用的瞬态错误自己愈。看完你能拿去跟同事讲清楚「一个生产级 Agent 的容错到底分几层、每层管什么」。
0. 一个让我换模型换到崩溃的下午
我第一次给 catbuddy 接 DeepSeek 的时候,信心满满------不就是再调一个 API 嘛。
结果一跑就炸。Claude 的 system prompt 是个顶级参数 ,DeepSeek 跟着 OpenAI 走,system 是消息数组里的一条 message ;Claude 的工具结果 role 是 user,DeepSeek 是 tool;Claude 流式响应是 SSE 事件,DeepSeek 是 data: [DONE] 结尾......我每接一家,AgentRunner 里就得多一个 if (provider === 'xxx'),越写越像意大利面。
更糟的还在后面。就算三家都接通了,真实世界还会变着花样捅你刀子:DeepSeek 半夜限流(429),Anthropic 偶尔 529 overloaded,模型时不时回我一个空字符串 ,写长文件写到一半被 max_tokens 截断......任何一个没兜住,跑了 40 轮的会话「啪」一下全没了,用户面前只剩一句 An error occurred。
这一篇就是那个下午之后我重新设计的答案。它分三段,从外到内:
- 适配层 ------一套
LLMProvider接口,把三家 API 的差异全焊死在子类里,AgentRunner 完全不知道底层是谁; - 故障转移------主模型挂了,自动顺着备选模型链往下试,AgentRunner 还是完全无感;
- 三层重试------单次调用里的瞬态错误(超时 / 空回复 / 截断),每一层各自吞掉自己能处理的,处理不了的往上传。
一句话串起来:先让一家挂了不影响别家,再让别家能顶上,最后让每次调用自己能扛住小风浪。
1. 适配层:一套接口,焊死三家差异
1.1 核心契约:AgentRunner 只认一个接口
整个 harness 里,只有一个地方真正调用 LLM ,那就是 provider.chatStreamWithRetry()。AgentRunner 调它的时候,根本不知道自己问的是 Claude 还是 DeepSeek,更不知道是不是已经故障转移到备选了。
这套抽象的核心是 LLMProvider 抽象基类(providers/base-provider.ts)------它定义统一接口,并把所有 Provider 共享的逻辑 收在基类里(重试、role 交替强制、错误分类);两个子类各自实现真正的 chatStream():
javascript
// providers/base-provider.ts
export abstract class LLMProvider {
abstract readonly name: string
abstract chat(opts: ChatStreamOpts): Promise<LLMResponse> // 非流式
abstract chatStream(opts: ChatStreamOpts): Promise<LLMResponse> // 流式 · 子类必须实现
// ⭐ 带重试的流式调用 ------ 模板方法,写在基类,子类不用 override
async chatStreamWithRetry(opts: ChatStreamWithRetryOpts): Promise<LLMResponse> { /* ... */ }
protected isTransientError(response: LLMResponse): boolean { /* ... */ } // 错误分类
protected enforceRoleAlternation(messages: LLMMessage[]): LLMMessage[] { /* ... */ } // role 交替
}
这是教科书里的策略模式------Provider 是可替换的「策略」,AgentRunner 是「上下文」。换个说法:基类管「所有家都一样的事」(怎么重试、什么算瞬态错误、收尾不能是 assistant),子类只管「这家独有的脏活」(消息格式、流式怎么拼)。整张图长这样:

注意右下角:OpenAICompatProvider 名字里的 "Compat" 不是凑数------任何遵循 OpenAI chat/completions 协议的服务都走它 :DeepSeek、Ollama、vLLM、OpenRouter、通义、Moonshot......一个类覆盖半个 AI 生态。工厂函数(providers/factory.ts)的路由逻辑因此简单到不讲理:
javascript
// providers/factory.ts ------ 只看 apiBase 就知道走哪条路
function makeProvider({ model, apiKey, apiBase }): LLMProvider {
if (apiBase?.includes('anthropic')) {
return new AnthropicProvider({ apiKey, apiBase, defaultModel: model })
}
return new OpenAICompatProvider({ apiKey, apiBase, defaultModel: model }) // 其余全走兼容层
}
这不是偷懒,是对「OpenAI 兼容 API 已经成了事实标准」这个现实的承认。
1.2 三家到底差在哪:消息格式的三处硬伤
子类干的最重的脏活就是消息格式转换 。上层永远用统一的 LLMMessage(里面工具结果就是老老实实的 role: 'tool'),转成各家 API 认的格式,是适配器的事。三处最容易翻车的差异:
| 差异点 | Anthropic | OpenAI 兼容(含 DeepSeek) |
|---|---|---|
| system prompt | 顶级参数 system: "..." |
消息数组里一条 { role: 'system' } |
| 工具结果的 role | role: 'user' + tool_result block |
role: 'tool' + tool_call_id |
| 流式里的工具调用 | SDK 事件回调,拼好直接给你 | 参数被拆成多个 chunk,要自己按 index 拼 |
第一处和第二处,Anthropic 适配器在构建请求时做提取和包装:system 从消息里 filter 出来塞进顶级参数,role: 'tool' 的消息被改写成 role: 'user' 里的一个 tool_result content block(Claude API 压根不认 role: 'tool')。这些全封装在适配器内部,AgentRunner 一无所知。
第三处最阴险------OpenAI 的流式会把一个工具调用的参数拆成好几个 chunk 发 ,每个 chunk 只带一小截 JSON 字符串。你不能拿到一个 chunk 就 JSON.parse,得按 index 攒齐了再解析:
javascript
// providers/openai-compat.ts ------ 按 index 聚合流式 tool_call
const toolCallMap = new Map<number, { id: string; name: string; args: string }>()
for await (const chunk of stream) {
for (const tc of chunk.choices?.[0]?.delta?.tool_calls ?? []) {
if (!toolCallMap.has(tc.index)) toolCallMap.set(tc.index, { id: '', name: '', args: '' })
const entry = toolCallMap.get(tc.index)!
if (tc.id) entry.id = tc.id
if (tc.function?.name) entry.name = tc.function.name
if (tc.function?.arguments) entry.args += tc.function.arguments // ⭐ 流式拼接
}
}
// 循环结束后才 safeParseJSON(entry.args) ------ 攒齐再解析
顺带提一个「能力探测」的小心机:虽然格式兼容,但功能不一定兼容。DeepSeek 不支持图片输入,catbuddy 用 apiBase.includes('deepseek.com') 探测出来后,自动把图片降级成一句文字占位([N attached image(s) not sent: this provider only accepts text...]),避免一个莫名其妙的 400。一个类覆盖半个生态的代价,就是得为这些「方言」留几个开关。
1.3 一个调了我一下午的隐蔽坑:DeepSeek 的 reasoning_content 必须往返带着走
这是本节的重头戏,也是我那天真正卡了几个小时的地方。
DeepSeek R1 这类推理模型,会在流式响应里多吐一个 reasoning_content------它的「思考过程」。这不是标准 OpenAI 字段,是 DeepSeek 借 OpenAI 格式塞的私货。捕获它很简单,跟捕获正文 content 一样,多接一个 delta:
javascript
// providers/openai-compat.ts · chatStream()
const reasoningDelta = (delta as { reasoning_content?: string }).reasoning_content
if (reasoningDelta) {
reasoningContent += reasoningDelta
await opts.onThinkingDelta?.(reasoningDelta) // 一路流到可折叠的「推理气泡」
}
// ...最后随 LLMResponse 一起返回
return { content, toolCalls, /* ... */ reasoningContent: reasoningContent || undefined }
到这一步都很顺。坑在多轮工具调用里。
你想想这个场景:模型先「思考」一通,然后决定调一个工具(tool_use)。我们执行完工具,把结果喂回去,让它接着想下一步。问题是:第二轮调用时,第一轮那段 reasoning_content 如果不带上,会发生什么?
会胡说。DeepSeek 在多轮工具调用里,依赖上一轮自己的推理链来保持思路连贯。你把它前一轮的「草稿纸」擦掉了,它就接不上了------轻则重复劳动,重则给出和上文矛盾的结论。我那天的现象就是:单轮问答好好的,一旦涉及连续工具调用,模型答得驴唇不对马嘴。
解法是让 reasoning_content 跟着对话往返,分两个阶段:
- SAVE 阶段(持久化) :模型这一轮返回的
reasoningContent,AgentRunner 必须把它存进这条 assistant 消息里(连同tool_use一起落到历史,也落到磁盘)。 - BUILD 阶段(取出回填) :下一轮构建请求时,适配器把存着的
reasoning_content重新挂回这条 assistant 消息上,再发出去。
BUILD 侧的代码就藏在 toOpenAIMessages 里,只有几行,但少了它就出 bug:
javascript
// providers/openai-compat.ts · toOpenAIMessages()
if (m.role === 'assistant') {
const hasTools = (m.toolCalls?.length ?? 0) > 0
const reasoning = m.reasoningContent ?? ''
if (hasTools || reasoning) {
row.reasoning_content = reasoning // ⭐ 把上一轮的推理链原样带回去
}
}
画成图,这个「往返」一目了然:

记一句话就行:对 DeepSeek 这类模型,推理链是对话状态的一部分,丢了就胡说。这也是「一套接口兜三家」最隐蔽的代价------你以为只是格式转换,其实有些「方言」连状态都得帮它管。
2. 故障转移:主模型挂了,自动切,AgentRunner 无感
适配层解决了「调哪家、怎么调」。下一个问题:主模型整个挂了怎么办? DeepSeek 半夜限流、Anthropic 529 overloaded------总不能让用户干等。
catbuddy 的答案是 FallbackProvider(providers/fallback.ts)。用户配一个「主模型」+ 一个 fallbackModels 列表,工厂函数把它们串成一条链。这里的精髓是:FallbackProvider 本身也是一个 LLMProvider------它继承同一个基类、对外暴露同一个接口。这是装饰器模式 :外层包了一层「先试主、不行试备选」的逻辑,但 AgentRunner 接到手里的,还是那个熟悉的 LLMProvider,它压根不知道自己手里这个是单模型还是一条故障转移链。
2.1 工厂怎么把链串起来
javascript
// providers/factory.ts
export function createProvider(config): LLMProvider {
const primary = buildProvider(config, defaults.model, defaults.provider)
const fallbackModels = config.agents.defaults.fallbackModels ?? []
if (fallbackModels.length === 0) return primary // 没配 fallback,直接返回裸 primary
const fallbacks: LLMProvider[] = []
for (const fb of fallbackModels) {
try {
fallbacks.push(buildProvider(config, /* 从预设名或内联配置解析 */))
} catch (err) {
console.warn(`[factory] fallback 初始化失败,跳过: ${err.message}`) // 吞掉,不阻塞别的
}
}
if (fallbacks.length === 0) return primary
return new FallbackProvider({ primary, fallbacks }) // ⭐ 装饰一层,对外还是 LLMProvider
}
fallbackModels 支持两种写法:字符串(引用 modelPresets 里的预设名,如 "fast"),或内联对象({ model: 'gpt-4o', provider: 'openai' })。某个 fallback 初始化失败(比如没配 API Key),catch 直接吞掉跳过,不连累其他------能用几个算几个。
2.2 FallbackProvider:按序试,永远给个有意义的兜底
javascript
// providers/fallback.ts · _tryWithFallback()
const providers = [this.primary, ...this.fallbacks]
for (let i = 0; i < providers.length; i++) {
const provider = providers[i]
try {
const response = await provider[method]({ ...opts, model: opts.model ?? provider.defaultModel })
if (response.finishReason !== 'error') {
if (i > 0) console.log(`[fallback] 已切到 fallback #${i}: ${provider.name}`)
return response // ✅ 这家成了,立刻返回
}
continue // ❌ 这家报错 → 直接试下一家
} catch (err) {
console.warn(`[fallback] ${provider.name} 抛异常: ${err.message}`)
continue
}
}
// 所有 provider 全挂
return { content: 'All providers failed. Please check your API keys and network connection.',
finishReason: 'error', /* ... */ }
整条链画出来就是一条「逐级降级」的瀑布:

这里有两个我特别想强调的设计判断:
一、不死磕「瞬态 vs 永久」,能试就试。 你可能觉得应该只在「瞬态错误」(限流、5xx)时才切下一家。但 catbuddy 的策略是:就算这家报的是配额耗尽这种看着「永久」的错,也继续试下一家 。原因很实在------isTransientError 的判断本来就是启发式的、不可靠;而且你在不同 provider 上的预算/配额情况可能完全不同。多试一家的成本,远低于错过一个本来能用的 provider。
二、FallbackProvider 自己覆盖了 chatStreamWithRetry ,避免重试爆炸。 注意:链式切换走的是各家的 chatStream,不是 各家的 chatStreamWithRetry。为什么?否则就成了「主模型重试 3 次 × 备选重试 3 次 = 9 次调用」的指数膨胀。故障转移管「换人」,单机重试管「同一个人再试试」,两件事得分开,不能叠乘。
最坏情况下那句 All providers failed. Please check your API keys and network connection.------它不是随手写的兜底。它一句话说清了三件事:发生了什么 (调用失败)、可能为啥 (Key 或网络)、你能干啥 (去检查)。对比一片空白的白屏,这条消息至少让用户有下一步。这是贯穿整个韧性设计的铁律:永远不在用户面前崩溃。
2.3 热切换:/model 命令背后,Provider 怎么换而不重启
故障转移是「被动」换模型。还有「主动」换------用户在对话框里敲一句 /model gpt-4o。
(/model 命令本身的三层路由我们在第 03 篇讲过了,这里只看它触发的热切换效果。)
热切换的核心诉求很苛刻:Agent 不能重启、不能中断当前对话,但下一轮就得用新模型。 而且换一个模型,要同步通知一串子系统------AgentRunner、记忆合并器 Consolidator、后台学习 Dream、子 Agent,它们全在用同一套 Provider。
这里有个很关键的优化:model_presets.ts 给每个配置算一个 Signature ------本质是「模型名 + 关键配置」的哈希(实现上是 ['model_preset', 预设名, JSON.stringify(预设配置)] 这样一个元组)。切换时先比 Signature:
javascript
// agent/loop.ts · _applyProviderSnapshot()
const sig = JSON.stringify(snapshot.signature)
if (sig === prevSig) return // ⭐ 配置没变,啥都不干,绝不重复 new 一个 Provider
// 真变了 → 才广播给所有子系统
this.provider = snapshot.provider
this.runner.setProvider(snapshot.provider) // Agent 执行器
this.consolidator.setProvider(snapshot.provider, /*...*/) // 记忆合并
this.dream?.setProvider(snapshot.provider, /*...*/) // 后台学习
this.subagents?.setProvider(snapshot.provider, /*...*/) // 子 Agent
Signature 一致就直接 return,避免「配置没变也重新初始化一遍 Provider」的浪费(重建一个 Provider 要重连 SDK、重新探测能力,不便宜)。整个切换全程同步、零重启、零中断 ------正在跑的那个 turn 用旧 Provider 跑完,下一个 turn 自动用新的 。AgentRunner.setProvider(newProvider),一行,搞定。
3. 三层重试:单次调用的瞬态错误,怎么各自自愈
故障转移解决的是「整个模型 不可用」。但更高频的,是单次调用里的小毛病:网络抖一下、模型回个空串、输出写到一半被截断。这些用不着换模型,就地自愈就行。
catbuddy 在这里铺了三层防线,关键设计原则是:每一层只吞自己能处理的错,处理不了的原样往上传。

注意这两层的「位置」:① Provider 重试在最底层 ,紧贴 API;③ AgentRunner 自恢复在最外层,因为只有跑完一整次调用、拿到完整响应,才知道模型是「回了空串」还是「被截断」。中间其实还夹着第 2 节那条 Fallback 链(「答不上来且重试无效 → 换模型」)。三者层层嵌套,从内到外:单机重试 → 换模型 → 内容级补救。
3.1 第一层:Provider 重试------只重试「该重试的」,指数退避
最底层是 chatStreamWithRetry(写在基类,所有 Provider 共享)。它干两件事:指数退避 + 只重试瞬态错误。
javascript
// providers/base-provider.ts
async chatStreamWithRetry(opts): Promise<LLMResponse> {
const maxAttempts = opts.retryMode === 'persistent' ? Infinity : 3
const baseDelays = [1, 2, 4] // 1s → 2s → 4s
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await this.chatStream({ /* ... */ })
if (response.finishReason !== 'error') return response // 成了
if (!this.isTransientError(response)) return response // 永久错,重试也没用,直接返回
const delay = response.retryAfter ?? baseDelays[Math.min(attempt, 2)] // 429 可能自带 retryAfter
await sleep(delay * 1000)
} catch (err) {
if (err.name === 'AbortError') throw err // ⭐ 用户 /stop,立刻穿透,绝不等退避
await sleep(baseDelays[Math.min(attempt, 2)] * 1000)
}
}
return { content: 'Error: max retries exceeded', finishReason: 'error', /* ... */ }
}
三个要点:
①「该不该重试」由 isTransientError 拍板,克制得很:
javascript
protected isTransientError(response: LLMResponse): boolean {
if (response.errorShouldRetry !== undefined) return response.errorShouldRetry
if (response.errorStatusCode === 429) return true // 限流 → 等等就过
if (response.errorStatusCode && response.errorStatusCode >= 500) return true // 服务端故障 → 换个实例可能就好
if (response.errorKind === 'timeout' || response.errorKind === 'connection') return true // 网络抖动 → 重连
return false // 401 Key 错、400 格式错 → 重试 100 次也没用,别浪费 token
}
429(Rate Limit)是特殊照顾的 :除了判定为瞬态可重试,它还会优先读响应里的 retryAfter------服务端明说「X 秒后再来」,那就等 X 秒,而不是傻等固定的 1/2/4。对 4xx(除 429)这种客户端错误,一律不重试------你 Key 错了、格式错了,重试再多次结果都一样,还可能把你推向更严厉的限流。
② AbortError 直接穿透,绝不重试。 用户点了 /stop,AbortController 触发。如果这时候你还卡在退避里等 4 秒,用户会觉得「/stop 按了仨秒了它还在吐字」。AbortError 不是「错误」,是「用户想停下来」------优先级最高,立刻穿透所有重试层。
③ 两种重试模式 :standard(默认,最多 3 次)给用户的正常对话------三次还不行多半是真有问题,再等没意义;persistent(无上限)给后台任务,比如 Dream 在后台提炼记忆、Consolidator 压缩历史------没人在等,多试几次无所谓。但不管哪种模式,AbortError 都穿透。
3.2 第二层:模型「答了,但答了个寂寞」------空回复重试
Provider 层管的是「模型能不能响应」。可有时候模型响应了,却给你一个空字符串------没 text,没 tool_calls,啥都没有。通常是它「卡住了」,或者上一个 tool result 把它搞懵了。
这种 Provider 层发现不了(在它看来这次调用「成功」了),得 AgentRunner 在循环里兜:
javascript
// agent/runner.ts
if (!finalContent?.trim() && emptyRetries < MAX_EMPTY_RETRIES) {
emptyRetries++
messages.push({
role: 'user',
content: 'Please provide your response to the user based on the conversation above.',
})
continue // 推一条引导消息,再转一轮
}
不报错,而是塞一条「请基于上文给出你的回复」进去,让它再试一次。最多 2 次。
3.3 第三层:输出被 max_tokens 拦腰截断------续写恢复
最后一种:模型正写一个长文件,写到一半撞上 max_tokens 上限,finishReason 变成 max_tokens,话说一半断了。
AgentRunner 检测到这个信号,发一条「接着写」让它续上:
javascript
// agent/runner.ts
if (response.finishReason === 'max_tokens' && lengthRecoveries < MAX_LENGTH_RECOVERIES) {
lengthRecoveries++
messages.push({
role: 'user',
content: 'Output limit reached. Continue exactly where you left off --- no recap, no apology.',
})
continue // 续写内容会接到之前的输出后面
}
最多 3 次------超过 3 次还没写完,多半是模型陷入了无限重复,再续也是浪费。
这条引导消息的措辞我反复磨过 :no recap, no apology(别复盘、别道歉)。不加这句,模型的典型反应是:「好的,让我继续。首先回顾一下我之前做了什么......」------然后半条命的 token 全花在复盘上,正事没干几个字。一句精准的提示词,省一半 token。这跟空回复那条「请基于上文给出回复」一样,都是用提示词把模型从歧路上拽回来。
顺带说一句:工具执行抛异常时,AgentRunner 也不崩溃,而是把异常包成 Error: ${err.message} 当作 tool result 喂回给模型------让模型自己看到错误、自己换参数或换工具。代码只负责如实报告,决策权留给 LLM。 这条线属于工具系统,前面第 04 篇细讲过,这里不展开。
4. 三段连起来:一条消息怎么被层层兜住
把三段拼回去,一次有惊无险的调用是这样的(看日志最直观):
javascript
[run] 调用 provider.chatStreamWithRetry()
[run] [fallback] primary (deepseek) 报 429 限流 ... ← 故障转移层接住
[run] [fallback] 已切到 fallback #1: anthropic/claude-sonnet ← 切到备选,成功
[respond] [DONE] turn completed {totalMs: 14000} ← 用户全程无感
AgentRunner 从头到尾只调了一次 chatStreamWithRetry,至于中间是重试了、还是切了模型、还是续写了------它一概不知道,也不需要知道。这就是第 01 篇那条主线在「韧性」这一层的兑现:每个器官只认接口、不认实现,改动被锁在单个器官内部。 加一家新 provider,AgentRunner 一行不改;调一调重试策略,Provider 之外的代码毫无感知。
这篇讲了什么?
- 适配层 :
LLMProvider抽象基类把「重试、role 交替、错误分类」这些公共逻辑收在基类,两个子类AnthropicProvider/OpenAICompatProvider各自实现chatStream()消化三家差异(system 是参数还是消息、tool 结果 role、流式 tool_call 拼接)。AgentRunner 只调chatStreamWithRetry(),永远不知道底层是谁。最隐蔽的坑:DeepSeek 的reasoning_content必须 SAVE 后在下一轮 BUILD 回填,否则多轮工具调用会胡说。 - 故障转移 :
FallbackProvider用装饰器模式把主模型 +fallbackModels串成链,对外还是同一个接口;「能试就试」不死磕错误类型,全挂了给一条有意义的兜底消息。/model热切换靠 Signature 比对避免重复初始化,当前 turn 跑完下个 turn 自动换,零重启。 - 三层重试 :① Provider 层指数退避(1s/2s/4s,最多 3 次,429 特殊处理、AbortError 穿透);② 空回复注入引导消息(最多 2 次);③
max_tokens截断注入「接着写别复盘」续写(最多 3 次)。每层只吞自己能处理的,处理不了的往上传------核心铁律是「永远不在用户面前崩溃」。
下一篇预告 :这三层重试是硬编码 在循环里的。可如果我想在工具执行前后加监控、接个 Langfuse 追踪、塞点项目特定的逻辑------总不能每次都去改 AgentRunner 的源码吧?下一篇就讲 catbuddy 怎么用 Hook 系统在循环的关键节点开洞,让你不碰一行核心代码就能把扩展插进来。