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 才不再是一段"看起来很聪明"的演示,而是一套可以长期运行、遇到问题还能自己找回路径的工程系统。
下一步,我会继续把这套恢复思路落到发布审批、内容哈希和结果回读上,让每一次重要写操作都有清晰的授权边界和可验证结果。