内容提要
本文介绍 .NET 的 FirstChanceException 机制:异常抛出瞬间即触发,即使被 catch 吞掉也能感知。以开源项目 OpenClaw.NET 为例,其工具执行、MCP 调用、记忆召回、LLM 重试等失败均被降级为字符串或警告,导致 Agent 变笨却无日志。挂上该事件可观测被吞异常,但只能记录不能拦截,需防递归与性能开销。
延伸解读
FirstChanceException 的观测定位
FirstChanceException 在异常抛出瞬间触发,早于任何 catch 处理,因此能捕获被吞掉的异常。但它只能观测,不能拦截或修改异常传播。在 OpenClaw.NET 中,工具执行、MCP 调用等失败被降级为字符串或警告,挂上该事件可看到原始异常,但无法阻止降级逻辑。
递归与性能风险
在 FirstChanceException 处理器中记录日志时,若日志代码自身抛出异常,会再次触发事件导致无限递归,需添加防护。此外,每次异常抛出都会触发事件,频繁异常会带来性能开销。在 Agent 场景中,重试循环可能抛出大量异常,建议采用采样或限流策略。
重抛导致的重复触发
使用 throw; 重新抛出异常会再次触发 FirstChanceException,因此同一异常可能在日志中出现多次。例如 OpenClaw.NET 的 AgentToolCallLoop 在并行工具调用失败时,会取消其他任务并重抛,导致重复记录。这是正常现象,不应误判为多次独立异常。
与 UnhandledException 的对比
UnhandledException 仅在异常未被任何 catch 捕获时触发,对于 OpenClaw.NET 这类有完善降级机制的系统,可能很少触发,但这不代表系统健康,只说明异常都被消化了。FirstChanceException 能反映真实的异常水位,帮助发现被降级掩盖的问题,如记忆召回失败导致的 Agent 变笨。
Q&A
什么是 .NET 的 FirstChanceException 机制?
FirstChanceException 是 .NET 提供的一个事件,在异常被 throw 的瞬间、尚未被任何 catch 块处理时触发。它允许开发者在异常被捕获或降级之前观测到异常的原始形态,但只能用于记录、告警或统计,不能拦截或阻止异常传播。
为什么 AI Agent 会突然变笨,但日志里却看不到错误?
因为 Agent 运行时为了保证对话不中断,会在工具执行、MCP 调用、记忆召回、LLM 重试等环节将异常降级为字符串或警告,导致异常被吞掉。例如工具失败被压缩成 'Error: Tool execution failed.',记忆召回失败只打 Warning 并继续运行。这些静默异常不会出现在常规日志中,但会降低 Agent 的回答质量。
如何在 .NET 中注册 FirstChanceException 事件?
通过 AppDomain.CurrentDomain.FirstChanceException += (_, e) => { ... } 注册。在事件处理程序中可以访问 e.Exception 来获取异常信息,进行日志记录、告警或统计。注意不能修改或清除异常,异常会继续向上传播。
使用 FirstChanceException 时需要注意哪些风险?
主要风险有三点:1) 不能拦截异常,只能观测;2) 如果 handler 自身抛出异常会导致无限递归,需加防护(如检查堆栈是否包含日志方法);3) 每次异常抛出都会触发事件,频繁异常会严重影响性能,建议配合采样或限流策略使用。
FirstChanceException 和 UnhandledException 有什么区别?
FirstChanceException 在异常刚被 throw 时触发,无论后续是否被 catch 都能感知;而 UnhandledException 只在异常未被任何 catch 块处理、即将导致进程终止时触发。对于有完善降级机制的系统,UnhandledException 可能很久不触发,但 FirstChanceException 能揭示被吞掉的异常。
FirstChanceException 在 AI Agent 排障中有哪些实际应用场景?
可以用于:1) 发现被静默吞掉的异常,如记忆召回超时导致 Agent 失忆;2) 配合 APM 工具构建异常热力图,定位异常高发区;3) 还原被包装前的原始异常,如看到 HttpRequestException: Connection refused 而非 'Error: Tool execution failed.'。