.NET 异常处理的"暗门":代码里写满 catch,你依然能抓住它——从一个 AI Agent 运行时的源码说起

你的系统里是不是也有这样的代码?

csharp 复制代码
try { DoSomething(); }
catch (Exception) { /* 啥也不干,异常?不存在的 */ }

异常被吞掉了,日志里没有,监控看不到,问题却真实存在。今天介绍一个 .NET 的"隐藏技能",让你无论异常是否被 catch,都能在第一时间感知到

为了不说空话,这次我们直接打开一个真实的开源项目------OpenClaw.NET (一个 NativeAOT 友好的 .NET AI Agent 运行时与网关,GitHub 搜 clawdotnet/openclaw.net),看看它的源码里藏着多少"被优雅吞掉"的异常,以及我们如何用 FirstChanceException 把它们全部揪出来。


〇、先看源码:Agent 运行时里,异常都去哪了?

跑过 AI Agent 的朋友可能有这种体验:Agent 突然"变笨了"------工具调用失败它不说,记忆检索挂了她也不提,只是回答质量肉眼可见地下降。

这不是玄学。打开 OpenClaw.NET 的源码,你会发现这是一个刻意设计的结果:Agent 运行时为了保证对话不中断,会在各个层面把异常"降级"掉。

形态一:工具执行失败 → 变成一句字符串

src/OpenClaw.Agent/OpenClawToolExecutor.cs 中,工具执行用一个 catch-all 兜底:

csharp 复制代码
catch (Exception ex)
{
    failureCode = ClassifyToolFailureCode(tool, ex.Message);
    failureMessage = ex.Message;
    toolFailed = true;
    // ......部分已知故障类型走 Blocked 分支......
    else
    {
        result = "Error: Tool execution failed.";   // ← 异常被压缩成一句话
        resultStatus = ToolResultStatuses.Failed;
    }
    _metrics?.IncrementToolFailures();
    _logger?.LogWarning(ex, "[{CorrelationId}] Tool {Tool} failed", ...);
}

异常对象在这里被"翻译"成 Error: Tool execution failed. 喂回给大模型。对话不会断,但真实的堆栈只有日志里才有------如果日志级别没配对,它就消失了。

形态二:MCP 工具失败 → 同样变成字符串

src/OpenClaw.Agent/Tools/McpNativeTool.cs

csharp 复制代码
catch (Exception ex)
{
    return $"Error: MCP tool '{localName}' failed: {ex.Message}";
}

形态三:记忆召回失败 → 打个 Warning 继续跑

src/OpenClaw.Agent/AgentRuntime.cs 里大量这样的代码:

csharp 复制代码
catch (Exception ex)
{
    _logger?.LogWarning(ex, "Memory recall injection failed; continuing without recall.");
    return false;
}

记忆召回失败,Agent 只是"失去记忆"继续回答。功能上不崩,效果上降级------这就是典型的"静默异常"。

形态四:LLM 调用重试耗尽 → 一句客套话

csharp 复制代码
catch (Exception ex) when (IsExpectedLlmFailure(ex))
{
    _metrics?.IncrementLlmErrors();
    _logger?.LogError(ex, "[{CorrelationId}] LLM call failed after all retries and fallbacks", ...);
    return AgentTurnResult.Completed(
        "Sorry, I'm having trouble reaching my AI provider right now. Please try again shortly.");
}

平心而论,这些写法没有问题 ------Agent 运行时必须韧性优先,不能因为一个工具报错就把整个对话炸掉。但代价是:异常被层层包装、降级、消化之后,排障时你看到的只是"Agent 不太好使"这个结果,而不是原因

有没有办法在不改这些代码的前提下,看到每一个异常"出生"的瞬间?

有。


一、异常处理的两个阶段

.NET 的异常处理机制,实际上分为两个阶段

阶段一:First Chance Exception(第一现场)

异常刚刚被 throw 的那一瞬间,CLR 会先触发一个"通知事件"------此时异常还没有 被任何 catch 块处理。你可以把它理解为"案发现场的第一时间报警"。

阶段二:Stack Walking(栈回溯)

CLR 从当前栈帧开始向上遍历,寻找匹配的 catch 处理器。如果找到了(比如 OpenClaw.NET 里那些降级 catch),异常被"消化";如果没找到,最终演变为未处理异常,进程可能终止。

复制代码
异常抛出
    ↓
【FirstChanceException 触发】← 你在这里可以观测,但不能阻止
    ↓
CLR 开始 Stack Walking,寻找 catch 块
    ↓
找到 catch → 执行 → 异常消失(被降级、被吞掉、被转成字符串)
未找到 catch → UnhandledException → 进程终止

二、代码实战:挂一个全局"监听者"

.NET 提供了 AppDomain.FirstChanceException 事件,注册方式极其简单:

csharp 复制代码
using System;
using System.Runtime.ExceptionServices;

class Program
{
    static void Main()
    {
        // 注册第一现场监听器
        AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
        {
            Console.WriteLine($"[FirstChance] 捕获到异常:{e.Exception.Message}");
        };

        try
        {
            throw new Exception("这是一个测试异常");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[Catch 块] 异常被处理了:{ex.Message}");
        }

        Console.WriteLine("程序正常结束");
    }
}

输出结果:

复制代码
[FirstChance] 捕获到异常:这是一个测试异常
[Catch 块] 异常被处理了:这是一个测试异常
程序正常结束

看到了吗?即使异常在 try-catch 里被"完美处理"了,FirstChanceException 依然能提前感知到它。

把它放到 OpenClaw.NET 的语境里:在网关启动处挂上这个 handler,那么无论是 MCP 工具超时、记忆召回失败、还是 LLM 重试过程中的每一次中间失败------只要异常被 throw 过,你都能看到它的原始形态,而不是被降级后的那句 "Error: Tool execution failed."。


三、重要:你不能在这里"吞掉"异常

很多开发者第一次用时会误以为可以在这里拦截异常,这是错误的

csharp 复制代码
// 错误示范:试图阻止异常传播
AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
{
    // e.Exception 是只读的,你无法修改或清除它
    // 异常会继续向上传播,不受你控制
};

FirstChanceException 的定位是"观测",不是"处理"。 你可以记录日志、发送告警、统计指标,但不能阻止异常去找它的 catch 块。

这一点在 Agent 场景下尤其要想清楚:OpenClaw.NET 那些降级 catch 是有意为之的韧性设计,你观测归观测,别想着绕过它们。


四、边界情况一览

场景 FirstChance 是否触发 备注
异常被 catch 并吞掉 ✅ 触发 这正是它的核心价值
throw;(保留栈的重新抛出) ✅ 再次触发 会走完整的新一轮流程
throw ex;(破坏栈的重新抛出) ✅ 触发 但 stack trace 会被重置
ThreadAbortException ✅ 触发 通常不建议特别处理
损坏进程状态异常(如 AccessViolationException ⚠️ 视版本而定 .NET 4+ 默认不触发,需特殊配置

注意第二行:OpenClaw.NETAgentToolCallLoop 里并行执行多个工具调用时,任何一个工具崩了会 linkedCts.Cancel(); throw; 取消其他兄弟任务再重新抛出------重抛会再次触发 FirstChance,所以你在日志里可能看到同一个异常出现多次,这是正常现象,别误判成"异常发生了两次"。


五、生产环境使用注意事项

1. 防止递归死循环

如果你在 FirstChance handler 里自己的代码又抛异常了,会无限递归

csharp 复制代码
AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
{
    // ✅ 加上防护,防止日志代码自身异常导致递归
    if (e.Exception.StackTrace?.Contains("MyLogger") == true)
        return;

    // 安全地记录日志...
    Logger.Warn("FirstChance 异常捕获", e.Exception);
};

2. 性能开销

每次异常抛出都会触发这个事件。异常抛出的成本本身就不低,加上 handler 的执行,频繁异常会严重影响性能。

这在 Agent 运行时里是个现实问题:一个陷入"调用失败 → 重试 → 再失败"循环的工具,一轮对话可能抛出几十上百次异常。建议配合采样或限流策略使用------比如按异常类型 + 工具名做聚合,每分钟最多上报 N 条,而不是每条都写日志。

3. 与 UnhandledException 的区别

特性 FirstChanceException UnhandledException
触发时机 每次异常抛出时 异常即将导致进程终止时
能否阻止进程崩溃 不能 有限(视 IsTerminating
异常是否已被 catch 尚未确定 已确定没有 catch
用途 观测/监控/诊断 临终遗言/兜底处理

OpenClaw.NET 这类有完善降级机制的运行时,UnhandledException 可能很久都不会响一次------但这不代表系统健康,只代表异常都被消化了。想看真实水位,得靠 FirstChance。


六、实际应用场景

场景 1:根治"静默异常"

项目中有些老代码喜欢这样写:

csharp 复制代码
try { CallThirdPartyApi(); }
catch { /* 静默失败,以为很优雅 */ }

上线后接口经常超时,但没有任何日志。挂上 FirstChanceException 后,所有被吞掉的异常都无所遁形。

场景 2:诊断 Agent 的"悄悄降级"

这就是 OpenClaw.NET 给我们展示的典型场景:Agent 回答质量下降,但没有错误日志。挂上 FirstChance 后你可能会发现------原来是记忆召回每次都在抛超时异常然后 continuing without recall,Agent 其实一直在"失忆"状态下工作。降级的路径是设计好的,但降级发生的频率和原因,只有观测了才知道。

场景 3:全链路异常监控

配合 APM 工具(如 SkyWalking、Elastic APM),在 FirstChance 中打上标记,构建异常热力图,哪怕异常被内部消化了,也能知道哪里是"异常高发区"。

场景 4:诊断偶发 Bug

某些难以复现的问题,往往是因为异常在某个深层库被 catch 后走了降级逻辑。通过 FirstChance 日志,你能看到异常原本长什么样 ,而不是被包装后的版本------比如不是 "Error: Tool execution failed.",而是底层的 HttpRequestException: Connection refused


七、总结

AppDomain.FirstChanceException 是 .NET 提供给我们的一个**"上帝视角"**------它不参与异常处理决策,但让你有机会看到每一个异常诞生的瞬间。

记住三个关键点:

  1. 它能让你观测到所有异常,无论是否被 catch
  2. 不能阻止异常传播或吞掉异常
  3. 生产环境使用要注意递归防护性能影响

OpenClaw.NET 的源码则给了我们一个很好的参照系:一个健壮的系统,必然到处都是有意的 catch;异常被消化不等于问题不存在。 观测与韧性,是一个硬币的两面------降级逻辑保证系统不崩,FirstChance 保证你能看见它为什么降级。

下次当你面对一个"明明感觉有问题但日志里什么都没有"的系统------或者一个"突然变笨"的 AI Agent------不妨试试挂上这个 handler,说不定会有意外收获。


本文涉及的源码github.com/clawdotnet/openclaw.net(MIT 协议,NativeAOT 友好的 .NET AI Agent 运行时,感兴趣的朋友可以 star 一下)