吃透 AI Agent 开发 · 第 32 篇 · 第六章 · Harness
部署后要面对网络、模型限流、上下文过长、外部服务不可用和进程崩溃。可靠性来自明确的降级和恢复协议。
部署后要面对网络、模型限流、上下文过长、外部服务不可用和进程崩溃。可靠性来自明确的降级和恢复协议。
生产故障从来不是一个异常
模型服务返回 429 时,系统可能已经消耗了一次请求预算;工具超时后,外部动作可能已经完成;进程崩溃时,文件写入可能只完成了一半。可靠性不是给所有异常加 retry,而是为每类不确定性设计下一步。
一条从故障到恢复的路径
flowchart LR
Detect[检测信号] --> Classify[分类: 可重试/需查询/不可恢复]
Classify --> Degrade[选择降级能力]
Degrade --> Persist[保存状态与证据]
Persist --> Resume[恢复或人工确认]
Resume --> Verify[验证最终事实]
“服务不可用”只能描述现象,不能直接决定动作。查询状态、切换只读能力、返回已有 artifact、让用户稍后继续,都是不同的恢复路径。
把降级写成矩阵
| 故障 | 保留能力 | 暂停能力 | 用户需要知道 |
|---|---|---|---|
| 模型限流 | 查看历史、导出结果 | 新的模型请求 | 预计重试时间和当前状态 |
| 上下文超限 | 摘要、按需恢复 | 继续注入原文 | 哪些内容被 offload |
| 工具超时 | 查询外部状态 | 盲目重放写操作 | 动作是否未知 |
| 进程崩溃 | 会话恢复、文件历史 | 未确认的副作用 | 可恢复范围 |
| 外部服务故障 | 本地只读检查 | 依赖远端的写动作 | 降级数据的新鲜度 |
用不可变事件保护恢复
interface RecoveryEvent {
sessionId: string
step: number
kind: 'model-requested' | 'tool-started' | 'tool-finished' | 'degraded' | 'crashed'
at: string
evidence: { callId?: string; artifact?: string; hash?: string }
}
function canReplay(event: RecoveryEvent): boolean {
return event.kind === 'model-requested' || event.kind === 'degraded'
}
能否重放应该由事件类型和副作用证据决定。写操作完成但结果丢失时,正确动作是查询或比对 hash,不是重新执行一遍。
q-code 的生产路径
| 顺序 | 源码路径 | 现场问题 |
|---|---|---|
| 1. 崩溃保护 | src/runtime/crash-guard.ts |
进程退出前保存了什么 |
| 2. 启动诊断 | src/runtime/startup-trace.ts |
启动慢和启动失败如何区分 |
| 3. 重试策略 | src/agent/retry.ts |
哪些请求允许重新发送 |
| 4. 审计查询 | src/observability/audit-cli.ts |
用户能否找到恢复证据 |
| 5. 本地面板 | src/dashboard/server.ts |
只读观测是否泄露原文 |
失败反例:所有故障都提示“请重试”
部署后要面对网络、模型限流、上下文过长、外部服务不可用和进程崩溃。若所有故障都提示“请重试”,用户可能重复写文件、重复发请求,系统也无法判断第一次动作到底有没有发生。
修复要让错误消息带上类别、请求标识、当前状态和下一步。能自动恢复的才显示重试;状态未知的先给查询;不可恢复的提供 artifact、日志位置和人工处理入口。
写一份值班手册
练习:选五个生产故障,给每个故障写检测信号、状态保存、降级策略、恢复动作和验证条件。再用故障注入逐项演练:限流、杀进程、断开工具服务、制造超长上下文和让写操作在响应前超时。
最后把手册中的每个动作映射到日志或 dashboard 字段。没有观测字段的降级方案,发生事故时仍然只能靠猜。
先选择部署形态,再决定可靠性手段
本地 CLI、桌面应用、常驻 Web 服务和队列 Worker 都能承载 Agent,故障边界完全不同。本地 CLI 与用户文件系统天然靠近,权限可借操作系统账户,进程退出后任务通常停止;Web 服务要隔离租户、持久化任务和控制并发;Worker 适合长任务,却要处理队列重复投递与租约。
不要把本地路径直接搬到服务器。服务器上的 cwd 是容器或共享磁盘,不是用户电脑;附件要通过受控上传,工具执行要在隔离 workspace,Session 与 artifact 需要对象存储或持久卷。相反,纯本地产品也不必为了“云原生”先引入分布式队列。
画清进程、存储、网络和信任边界:模型调用从哪发出,代码在哪里执行,文件由谁拥有,凭据在哪注入,任务状态由谁持久。部署图比 Dockerfile 更早决定可靠性。
一次请求至少有四层 Deadline
用户愿意等 5 分钟,不代表每个模型请求都可等 5 分钟。总任务 deadline、单步模型 timeout、单工具 timeout、外部连接 timeout 应层层收窄,并共享取消信号。
interface Deadlines {
runEndsAt: number
modelRequestMs: number
toolDefaultMs: number
connectMs: number
gracefulShutdownMs: number
}
调用前计算剩余 run 时间。只剩 8 秒时,不再启动预计 30 秒的子 Agent;给最终状态保存和用户提示预留时间。timeout 发生后清楚说明是连接、首 token、总模型请求还是工具执行,不用一个 ETIMEDOUT 覆盖所有阶段。
定时器在模型切换、用户取消、请求完成和进程退出时清理,避免旧 timer 在下一轮误触发。后台任务有自己的 deadline,不借用已结束前台 run 的 Signal。
重试预算比“最多三次”更接近现实
第一次请求 500 毫秒失败,重试三次合理;第一次已等 50 秒,总 deadline 60 秒,再重试三次只会拖过用户预算。Retry Policy 同时看错误类型、attempt、elapsed、remaining deadline、provider Retry-After 和总成本。
指数退避加入 jitter,避免多个 Worker 同时恢复造成惊群。429 尊重服务端等待建议,401/403 不重试,schema/参数错误先修请求,context length 先压缩,内容过滤转为用户可解释错误。
重试记录 attempt 与前次 error class,最终 trace 能看出时间花在哪里。自动重试只用于请求幂等且副作用未发生的阶段;工具调用边界另行判断。
幂等性要从业务工具设计进去
创建工单、发邮件、扣款、发布版本都有重复风险。客户端生成稳定 idempotencyKey,与 callId、目标和规范化参数绑定;服务端支持时用它去重,重试返回同一结果。
服务端不支持时,工具提供 inspect:按外部 requestId 或业务唯一键查询。网络断在响应前,状态进入 unknown,先查后试。无法查询也无幂等的动作默认不自动重试,交给用户确认。
本地写文件可用写前/写后 hash 判断,原子 replace 减少半写;Shell 是组合动作,通常不能整体宣称幂等。即使同一测试命令可重复,里面的 setup 脚本也可能写数据库。
“重试按钮”应展示将重试的是模型请求、只读查询还是外部写动作。用户不该在不知道风险时点一个通用按钮。
Circuit Breaker 防止坏依赖拖垮全部请求
MCP 服务连续失败时,每个任务都等 30 秒再报错,会把线程、连接和用户耐心耗尽。断路器按依赖和错误类型统计:达到阈值 open,短时间内快速失败;到期 half-open 放少量探测;成功后 close。
认证错误与配置错误可以直接 open 到配置变化,网络 503 按时间恢复。断路器状态进入 Runtime capability summary,本轮模型不再看到不可用工具,界面显示服务降级。
断路器不是删除错误。Audit 记录首次、状态变化和探测结果,指标按依赖聚合;快速失败仍返回结构化原因和预计恢复时间。
共享外部服务的多个工具使用同一 breaker,避免每个工具各自把服务打满。不同租户/endpoint 的故障又要隔离,不能一个客户认证失败让全局 Jira 熔断。
Provider Failover 不是换一个模型名
主模型不可用时切备用,需要确认消息 part、reasoning、tool choice、上下文窗口、图片和 structured output 能力。历史 assistant 中的 provider 私有 reasoning 可能无法直接发送给备用模型。
Failover adapter 先做能力检查和安全转换:保留通用 user/assistant/tool 协议,无法兼容的 reasoning 转为不透明摘要或停止恢复;工具调用 callId 保持关联;窗口更小时先压缩。显式 toolChoice=required 若备用不支持,不能偷偷改 auto。
模型变化提示用户一次,trace 记录 from/to/reason。备用模型权限不扩大,价格和 reasoning 强度进入预算重算。某些高风险任务宁可暂停,也不在能力明显不足的模型上自动继续。
Admission Control 在过载前拒绝新工作
Web 服务或共享 Worker 若无限接任务,模型 rate limit、CPU、内存、文件句柄和队列都会崩。入口按租户、任务类型和资源估算做 admission:短只读请求可排队,长写任务需要可用 workspace 与预算,超过上限立即返回队列位置或稍后再试。
并发 semaphore 不只控制模型请求,还要控制 Shell、浏览器、embedding 和外部 API。优先级避免后台索引刷新抢走交互请求,但低优先级也要防永久饥饿。
队列长度、等待时间和拒绝率是容量指标。排队 20 分钟后才运行一个依赖旧工作区状态的任务,可能已经失效;任务开始前重新验证版本和 approval。
本地 CLI 也有过载:四个 SubAgent、三个测试进程和索引 watcher 同时争 CPU。动态调度保留主交互槽,允许用户取消,而不是让终端看似卡死。
Backpressure 要沿事件链传递
模型每秒发数百 delta,UI 慢、审计磁盘慢、WebSocket 客户端断线。无限 buffer 最终内存爆炸;让最慢消费者同步阻塞又会拖断模型流。
事件按重要性分级。文本 delta 可合并,进度采样可丢并计数,tool start/result、approval、stop、usage 必须保留。每个 subscriber 有界队列和 overflow policy,核心 assembler先完整保存协议 part。
Web 客户端重连时从 session/event cursor 恢复稳定事件,不回放每个 spinner 帧。若用户已离开页面,后台任务继续与否由任务合同决定,不由 socket 生命周期偶然决定。
Checkpoint 只在一致边界落盘
模型消息完整组装、工具策略决定、工具终态确认、任务节点完成是稳定 checkpoint。工具执行到一半写 completed=false 并不等于可安全恢复,必须保留 callId、effect state 和 inspection action。
Checkpoint 写入失败时,副作用前与副作用后处理不同。执行前无法持久化,可以阻断动作;执行后无法写状态,先保留本地 receipt/crash report,提示“动作可能已完成但 checkpoint 失败”。
格式带 schemaVersion、run/session/task/toolset/prompt hash 与 artifact refs。升级读取旧版迁移;未知新版只读展示,不从头重放。
队列 Worker 使用 lease 时,checkpoint 与 ack 顺序很关键:先持久化终态再 ack;Worker 崩溃导致消息重投时,根据 idempotency/call receipt 恢复,不重复副作用。
Graceful Shutdown 是一个有限时间的协议
收到 SIGINT/SIGTERM 或 Windows 控制事件后,入口停止接收新请求,通知 UI “正在收尾”,abort 前台模型与可取消工具,等待后台任务按策略 checkpoint,关闭连接,flush 存储,最后退出。
每步有单独 deadline。Langfuse flush 超时不能阻塞 Session;无法终止的子进程记录 pid/jobId 与 unknown;终端光标恢复即使其他清理失败也要执行。
第二次 Ctrl+C 可以强制退出,但先写最小 crash/forced-exit 记录。不要在 signal handler 里执行大量不安全异步逻辑,集中触发 Runtime shutdown controller。
容器平台的 termination grace period 要大于应用收尾预算。Kubernetes readiness 先变 false,避免负载均衡继续送流量;liveness 不应因一个慢模型请求重启整个 Pod。
Crash Guard 保护证据,不承诺自动修复
uncaughtException、unhandledRejection、进程信号和 TUI 渲染错误可能发生在正常事件管线之外。Crash Guard 使用裸 stderr,避免依赖已经损坏的 Ink;报告写受控目录,文件名含时间与随机 ID。
报告包含版本、平台、命令模式、启动阶段、session/run/last event ID、错误类型与脱敏 stack、当前是否有活动工具。不要写 API key、完整 endpoint、Prompt、文件内容、Shell 输出和工具参数。
下次启动可提示发现未处理 crash,并给本地路径与恢复会话选项。Crash report 是调查入口,不自动执行上次 pending tool。恢复器依据 Session/Audit/File History 判断。
测试 Crash Guard 时关闭真实 signal 注册,mock exit/stderr/home,避免测试进程退出或写用户目录。
会话与 Artifact 的灾难恢复
本地产品至少备份 Session metadata/transcript、Memory 和用户配置;File History、Shell spill 和 SubAgent artifact 体积大,可按保留期与重要性选择。备份加密并记录版本,恢复到新机器时不恢复 API key 明文和绝对路径权限。
服务器部署明确 RPO/RTO:允许丢最近多少事件,多久恢复任务。Append-only event 先落持久存储,再向用户确认关键副作用;对象存储 artifact 使用 hash 验证。
恢复演练从备份启动隔离实例,随机抽会话、记忆、任务与 artifact 读取,验证链接没有断。只有“备份任务成功”指标而没做 restore,不能证明可恢复。
删除请求也传播到备份策略,说明延迟与保留。高敏 artifact 不因“可能排障”无限保存。
数据格式迁移要支持回退
发布新版本前扫描现有 schema 版本,迁移先备份或采用 copy-on-write;转换完成校验数量/hash,再原子切换。应用启动发现迁移失败,进入只读诊断,不用半新半旧数据继续执行。
向前兼容可以忽略未知可选字段,不能忽略未知事件语义。旧版本读取新会话若不能理解工具状态,应拒绝重放,至少允许导出。
数据库 migration 与代码 deploy 协调 expand/contract:先加兼容字段,双读/双写,迁移数据,切换读取,最后删除旧字段。一个 Pod 新、一个旧时仍可工作。
本地 JSONL 也要版本策略。迁移器写新文件后 replace,旧原件保留到确认;不要逐行原地改,进程中断会毁掉唯一副本。
版本发布需要 Changelog 和运行时提示
用户可见命令、环境变量、默认权限、Session 格式和模型行为变化进入 Changelog。构建从 tag/commit 生成包内 changelog,启动比较上次版本,只提示一次重要变化。
自动更新先支持 dry-run,说明当前版本、目标版本、包管理器和将执行命令;失败不破坏现有安装。全局 CLI 更新与当前运行进程分开,新版本下次启动生效。
供应链要锁定依赖、生成 provenance/校验、扫描已知漏洞,发布 token 最小权限。Agent 自身拥有 Shell 和文件能力,依赖被劫持的风险比普通展示应用更高。
Secrets 不应该进入通用 Config 对象的日志表示
模型 API key、MCP token、Langfuse secret、企业 Infra token 从环境或系统 secret store注入,EffectiveConfig 保存受控引用或值但自定义 inspect/toJSON 永不输出。错误消息只显示是否配置、脱敏 endpoint 与来源。
SubAgent 和 Shell 子进程只继承必要变量,不复制整个 process.env。测试 fixture 使用假 secret,Audit/Crash/Eval scanner 断言不会出现。
多租户服务凭据按租户隔离,cache key 含租户与权限;不能把 A 的检索/Prompt Cache 内容服务给 B。访问 secret 的工具单独授权和审计。
沙箱与最小 OS 权限降低未知风险
路径策略和危险命令检查都不是完整沙箱。服务器执行不可信代码时使用容器/微虚机、只读基础镜像、临时 workspace、无特权用户、网络限制、CPU/内存/进程/磁盘配额和生命周期清理。
只挂载任务需要的文件,不挂 Docker socket、宿主 home 和云 metadata 凭据。网络默认 deny,再按工具目的地开放;DNS 与重定向同样受控。
本地 CLI 无法完全隔离用户账户,默认限制 cwd、受信只读目录和显式开关,危险动作审批并支持 worktree/snapshot。向用户诚实说明 Shell 与外部进程不在 rewind 完整保证内。
SLO 应围绕任务,而不是只有 API 200
HTTP 返回 200、Agent 却改错文件,服务可用性没有意义。可以定义:交互首状态 p95、普通只读任务成功率、写任务证据完整率、取消生效率、恢复成功率、禁止副作用零容忍、成本/步骤预算达标率。
模型提供商错误率是依赖指标,任务成功率是产品指标。两者一起看:provider 稳定但任务下降,可能是 Prompt/工具回归;provider 限流但本地降级有效,用户仍能查看历史。
错误预算用于决定是否继续发布功能。安全越界不使用普通错误预算抵消,属于立即阻断/事故。
SLO 分任务类型,不让简单问答的高成功率掩盖长写任务持续失败。
告警必须能导向动作
“错误率升高”太宽泛。告警带依赖/版本/任务类型、开始时间、影响范围、相关 dashboard 查询和第一步 runbook。429 激增先降并发并看 Retry-After;tool unknown 增加先停止自动写重试;Session 写失败先阻断新副作用。
避免按每个 event 发告警。指标窗口、阈值和去重降低噪声,安全事件则即时。告警恢复也记录,避免值班人不知道是否仍在影响。
本地产品不一定有 PagerDuty,可以在 TUI/启动诊断显示“审计目录不可写、回滚保护不可用”,高风险能力随之禁用,而不是后台静默告警。
值班手册按“现在能证明什么”组织
每个故障条目包含检测信号、用户影响、立即止损、证据位置、是否可能有副作用、恢复步骤、验证、升级条件和事后动作。
例如 MCP 写调用超时:暂停该 server 写工具;按 callId 查询远端;找到结果则补本地 receipt,明确未执行则在用户确认后重试,无法查询保持 unknown;验证 Registry 无重复工具和后续请求已降级。不是简单“重启 MCP”。
手册命令默认只读,路径用变量/说明而非私人绝对路径;操作生产必须有审批。每季度演练并更新,过期命令会在事故中放大伤害。
故障注入从确定性边界开始
Fake provider 可在首 token 前 429、reasoning 中断、tool call 后断流;Fake tool 可延迟、拒绝、返回超长输出、写成功后丢响应;SessionStore 可在 append 或 metadata replace 失败;MCP 可断线/改 schema。
断言的不只是最终错误文本:状态进入 retry/unknown/degraded,消息账本完整,副作用不重复,artifact 可恢复,UI 有终态,Audit 有原因,下一轮能力正确收窄。
进程级演练在隔离 fixture 中 kill CLI,重启恢复;不要在真实用户仓库模拟破坏。网络 chaos 设置明确范围和恢复,避免影响其他任务。
一次五故障桌面演练
场景从“修改配置并发布测试环境”开始。第一步模型 429,按退避成功;写文件后 Session append 失败,系统报告文件已改但 checkpoint 未完成并保留 hash;测试 Shell 超时,进程树未确认停止,状态 unknown;MCP 发布在响应前断线,禁止盲重试;随后进程被 kill。
重启后恢复器读取 file-history/audit,确认本地文件版本;查询旧测试 job;MCP server 仍不可用,发布保持 unknown;当前模型使用新配置。用户可以导出已有 artifact,不需从零重做分析。
演练通过的标准不是系统自动修好所有东西,而是每个“不知道”都有具体证据和下一步,没有重复副作用,没有把旧权限带回来。
发布前的可靠性清单
- Early commands 在模型、网络和 TUI 损坏时可用。
- 核心与可选依赖的 ready/degraded 状态可解释。
- 模型、工具、run、shutdown deadline 分层且可取消。
- 重试按错误与幂等判断,写状态 unknown 先查询。
- Rate limit、队列、CPU、磁盘和事件消费者有背压。
- Session、Task、Memory、Snapshot、Artifact 有版本和恢复测试。
- Crash report 脱敏,Graceful Shutdown 有总时限。
- 当前权限/模型在恢复时重新计算。
- Dashboard loopback 只读,不返回原文与绝对路径。
- SLO、告警、runbook 和故障注入能连成行动。
清单不能替代实际演练,但能阻止“包构建成功”被当成交付完成。
可靠性的最后一个边界:诚实
系统无法确认远端是否写入,就说 unknown;Shell 修改不在 file rewind 覆盖,就提前说明;外部观测失败,本地 artifact 仍在;旧记忆过期,要求验证。可靠产品不是永远不失败,而是不把不确定包装成成功。
用户敢把任务交给 Agent,往往不是因为它从不报错,而是出错时仍知道发生了什么、可以停在哪里、哪些内容能恢复、下一步不会扩大伤害。部署工程最终服务的就是这种可预测性。