AI Agent 为什么总在最后一步失败?从启动异常到可恢复工作流

OK,OK,大家好,欢迎大家来到大鹏 AI 教育,我是张大鹏。

最近我在整理一套由 AI Agent 驱动的内容工作流:前面要做数据采集和选题判断,中间要生成文章,最后还要调用浏览器完成保存、发布和结果核验。

单独看每一步都不复杂,真正连续运行起来以后,我却遇到了一个非常典型的问题:

Agent 能完成 90% 的工作,却经常倒在最后 10%。

有时浏览器明明刚启动,健康检查却失败;有时任务放置一段时间以后,下一次调用突然断开;还有一次,数据已经成功采集并写入数据库,命令行只是在输出文章标题时崩溃了。

这些故障表面上互不相干,背后却指向同一个工程问题:我们只设计了"任务怎么成功",没有设计"任务失败后如何判断、恢复和验证"。

这篇文章不再介绍 Agent、MCP 或工具调用的基础概念,而是复盘三个真实故障,看看怎样把一条"偶尔能跑通"的自动化链路,改造成可以诊断、可以恢复、可以验收的工程工作流。

一、Agent 的最后一步为什么最容易失败

一个完整的 Agent 任务通常会经过下面几层:

text 复制代码
用户目标
  ↓
任务规划
  ↓
模型推理
  ↓
工具调用
  ↓
浏览器或本地进程
  ↓
外部平台
  ↓
结果回读与验收

越往下,模型能够直接控制的东西越少。

  • 🧠 推理层失败,通常可以重新生成或调整提示词。
  • 🔧 工具层失败,需要理解参数、超时和返回值。
  • 🌐 浏览器层失败,可能涉及进程、页面、登录状态和网络。
  • 🧾 平台层失败,还要区分保存失败、提交失败和页面显示延迟。
  • ✅ 验收层失败,最麻烦:任务可能已经成功,只是我们没有正确读到结果。

因此,"最后一步失败"并不一定意味着最后一步有问题。它也可能是前面某个组件早已进入异常状态,只是直到最终提交时才暴露出来。

我的处理原则是:先缩小故障边界,再修改代码。不要看到浏览器报错就立即重启,也不要看到超时就无限增加超时时间。

二、故障一:浏览器启动失败,但真正的问题是插件资源

第一次故障发生在浏览器服务启动阶段。

上层看到的现象非常简单:浏览器没有进入 ready 状态,后续所有工具调用都无法执行。继续向下检查后,我发现底层启动库抛出了这样的错误:

text 复制代码
manifest.json is missing.
Addon path must be a path to an extracted addon.

原来的兼容逻辑只识别一个固定错误码:

js 复制代码
if (error?.code !== 'INVALIDADDONPATH') {
  throw error;
}

问题在于,依赖升级以后,同一种故障不再保证携带原来的错误码,而是变成了明确的错误消息。业务含义没有变化,错误的"外观"却变化了。

如果继续只判断错误码,原本设计好的插件降级逻辑就永远不会触发。

1. 把错误识别从调用流程中拆出来

我没有在 catch 中继续堆叠条件,而是先提取一个窄小的错误分类函数:

js 复制代码
function isInvalidAddonPathError(error) {
  return error?.code === 'INVALIDADDONPATH'
    || error?.message ===
      'manifest.json is missing. Addon path must be a path to an extracted addon.';
}

启动逻辑只负责决定是否降级:

js 复制代码
try {
  return await resolveLaunchOptions(launchConfig);
} catch (error) {
  const excludedAddons = launchConfig.exclude_addons || [];

  if (!isInvalidAddonPathError(error)
      || excludedAddons.includes('UBO')) {
    throw error;
  }

  return resolveLaunchOptions({
    ...launchConfig,
    exclude_addons: [...excludedAddons, 'UBO'],
  });
}

这样改有三个好处:

  • 🔍 旧版本错误码和新版本错误消息都能被识别。
  • 🛡️ 只对已知的插件路径问题降级,其他异常继续原样抛出。
  • ♻️ 已经排除插件后仍然失败,不会进入无限重试。

2. 为什么不能捕获所有错误后都重试

最省事的写法似乎是"启动失败就禁用插件再试一次"。但这会掩盖真正的配置错误,例如浏览器文件缺失、权限不足或启动参数错误。

Agent 工程中的重试必须满足两个条件:

  • 🎯 能确认失败属于可恢复类别。
  • 🧯 重试动作不会扩大副作用。

否则,重试只是让错误发生得更晚、更难定位。

三、故障二:浏览器启动成功,却在空闲后被自动回收

修复启动问题后,短任务已经可以稳定执行。但工作流空闲一段时间,再次调用浏览器时,/ready 偶尔会变成不可用。

这次不是浏览器崩溃,而是空闲回收策略主动关闭了后台实例。

原来的保留规则只照顾可见浏览器:

js 复制代码
function preserveBrowserWorkspaceOnIdle(distribution) {
  return distribution?.displayMode === 'headed';
}

这个规则用于普通服务端浏览器没有问题:后台实例不用时释放,能够节约资源。但在本地 Agent 工作流中,系统浏览器和受管浏览器同时承担了会话连续性与工具就绪契约。

它们虽然是无界面的,却不是一次性进程。

1. 不要把 headless 等同于 disposable

最终我把"是否保留"从单一显示模式判断,调整为浏览器身份判断:

js 复制代码
function preserveBrowserWorkspaceOnIdle(distribution) {
  return distribution?.displayMode === 'headed'
    || ['system', 'managed'].includes(distribution?.mode);
}

这里最重要的不是多加了两个枚举值,而是改变了资源治理的依据:

是否可以回收,不应该只看它有没有窗口,还要看它是否承载长期会话和稳定服务契约。

2. 保留后台浏览器不代表永远不关闭

常驻策略必须保留明确的退出边界:

  • 🧑 用户主动关闭时立即关闭。
  • 🛑 服务整体退出时统一清理。
  • 🚑 进程损坏且无法恢复时允许重建。
  • 💤 只有普通临时会话才参与空闲回收。

这样既能维持本地 Agent 的连续性,也不会让异常进程无限堆积。

四、故障三:任务已经成功,却在输出结果时显示失败

第三个问题最容易造成误判。

我连续采集了多组公开文章数据。浏览器执行正常,数据也已经写入 SQLite,但 CLI 在输出 JSON 时出现了:

text 复制代码
UnicodeEncodeError:
'gbk' codec can't encode character

原因是文章标题包含 emoji、不间断空格等字符,而 Windows 控制台仍在使用传统代码页。

从用户视角看,命令退出码是 1,自然会认为采集失败。如果 Agent 随后自动重试,就可能重复打开页面、重复采集、重复写入。

1. 把"业务执行"和"结果展示"分开判断

这类任务至少应该记录三个状态:

text 复制代码
EXECUTED   业务动作已经执行
PERSISTED  结果已经持久化
REPORTED   结果已经成功输出

只有一个笼统的 FAILED,Agent 就无法判断应该重新执行任务,还是只重新读取已有结果。

2. 在 CLI 边界统一使用 UTF-8

修复代码非常小:

python 复制代码
def _configure_utf8_stdio() -> None:
    for stream in (sys.stdout, sys.stderr):
        reconfigure = getattr(stream, "reconfigure", None)
        if callable(reconfigure):
            reconfigure(encoding="utf-8", errors="strict")

然后在 CLI 入口执行:

python 复制代码
def main() -> None:
    _configure_utf8_stdio()
    cli()

我选择 errors="strict",而不是忽略无法编码的字符。因为 JSON 是机器可读接口,静默丢字符可能破坏标题、链接或后续内容哈希,直接失败反而更安全。

五、把三个修复提升为一套恢复状态机

只修复三个具体 Bug 还不够。下一次依赖升级或页面变化,工作流仍可能在别的位置失败。

我把任务过程抽象成下面几个状态:

text 复制代码
CREATED
  ↓
PRECHECKED
  ↓
EXECUTING
  ↓
PERSISTED
  ↓
VERIFYING
  ↓
SUCCEEDED

异常不直接回到起点,而是根据失败位置进入不同恢复路径:

text 复制代码
启动失败 ──→ 修复运行环境 ──→ PRECHECKED
执行超时 ──→ 查询幂等记录 ──→ EXECUTING / PERSISTED
输出失败 ──→ 读取持久化结果 ──→ VERIFYING
回读失败 ──→ 延迟后重新核验 ──→ VERIFYING
需要登录 ──→ 人工接管 ──→ PRECHECKED

每个阶段都要留下最小证据

  • 🩺 PRECHECKED:依赖服务 ready、登录状态可用。
  • 🆔 EXECUTING:任务 ID、目标对象和幂等键已经生成。
  • 💾 PERSISTED:本地记录或远端草稿 ID 已保存。
  • 🔎 VERIFYING:通过独立读取确认标题、正文或公开地址。
  • 🏁 SUCCEEDED:最终结果满足用户最初提出的目标。

尤其需要注意:工具返回成功,不等于目标完成。

例如"点击发布按钮成功"只证明点击动作发生了;只有重新打开公开地址,并读到正确标题和正文,才能证明文章真正发布成功。

六、这次我是怎样验证修复的

每个改动都先运行最窄的测试,再验证真实链路。

浏览器侧重点覆盖:

  • 🧩 旧错误码仍然触发插件降级。
  • 🆕 新错误消息也能触发同样降级。
  • 🚫 未知错误不会被错误吞掉。
  • 🔁 已排除插件时不会无限重试。
  • 💤 系统与受管后台浏览器在空闲时保持存活。
  • 🧹 普通临时后台会话仍然可以被回收。

CLI 侧执行了聚焦测试和静态检查:

powershell 复制代码
.\.venv\Scripts\python.exe -m pytest -q `
  tests/test_research.py tests/test_facades.py

.\.venv\Scripts\python.exe -m ruff check `
  src\ruyi_juejin\facades\cli.py

最后再执行真实数据读取,确认包含中文、emoji 和特殊空格的 JSON 能完整输出。

这里的验证顺序也很重要:

  • 🧪 单元测试证明分支逻辑符合预期。
  • 🔗 集成测试证明组件之间能够协作。
  • 🌍 真实回读证明用户目标确实完成。

三者不能互相替代。

七、我最终得到的四条工程经验

  • 🧭 先分类失败,再决定是否重试。 不知道故障类别时,盲目重试只会制造更多噪声。
  • 🧱 状态要比日志更可靠。 日志告诉我们发生过什么,持久化状态告诉 Agent 应该从哪里继续。
  • 🔐 重要写操作必须幂等并可回读。 发布、创建、付款等动作尤其不能依赖"再点一次试试"。
  • 验收必须回到用户目标。 进程存活、接口返回 200、按钮点击成功,都只是中间证据。

写在最后

AI Agent 真正进入生产环境以后,决定稳定性的往往不是模型有多聪明,而是系统能否回答三个问题:

  • 🔍 现在失败在哪一层?
  • ♻️ 可以从哪个状态继续?
  • ✅ 用什么证据证明目标已经完成?

当这三个问题都有明确答案,Agent 才不再是一段"看起来很聪明"的演示,而是一套可以长期运行、遇到问题还能自己找回路径的工程系统。

下一步,我会继续把这套恢复思路落到发布审批、内容哈希和结果回读上,让每一次重要写操作都有清晰的授权边界和可验证结果。

相关推荐
武子康1 小时前
生产环境的模型路由不是一次难度分类:从硬约束可行域到状态检查点升级
人工智能·llm·agent
m沐沐1 小时前
【计算机视觉】OpenCV 物体跟踪——原理、算法与CSRT跟踪器实战
人工智能·python·深度学习·opencv·算法·计算机视觉·人脸识别
rain_sxr1 小时前
一个模型打天下:多模型路由前端的策略层与降级兜底
人工智能
墨舟的AI笔记1 小时前
前端 Prompt 工程化:模板版本管理与 A/B 评测的工程闭环
人工智能
前方视点1 小时前
给我推荐个AI写小说的工具?资深创作者的FeelFish实战测评
人工智能
没刮胡子2 小时前
AI完全离线的ASR语音识别+TTS语音合成+大模型对话
人工智能·ai·语音识别·tts·asr
海兰2 小时前
【高速缓存】 RedisVL MCP 运行指南(下)
人工智能·redis·哈希算法·高速缓存
aneasystone本尊2 小时前
Headroom 上手:wrap 与 proxy 两种接入方式实战
人工智能
老猿AI洞察2 小时前
智能视觉检测平台——完整商业化项目全功能详解
人工智能·yolo·计算机视觉·视觉检测