加载中...
  • 从本地 Agent 到可交付产品:可靠性、部署与降级 loading

    吃透 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,往往不是因为它从不报错,而是出错时仍知道发生了什么、可以停在哪里、哪些内容能恢复、下一步不会扩大伤害。部署工程最终服务的就是这种可预测性。

    本文目录
    本文目录