你的系统里是不是也有这样的代码?
csharptry { 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.NET 的 AgentToolCallLoop 里并行执行多个工具调用时,任何一个工具崩了会 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 提供给我们的一个**"上帝视角"**------它不参与异常处理决策,但让你有机会看到每一个异常诞生的瞬间。
记住三个关键点:
- 它能让你观测到所有异常,无论是否被 catch
- 它不能阻止异常传播或吞掉异常
- 生产环境使用要注意递归防护 和性能影响
而 OpenClaw.NET 的源码则给了我们一个很好的参照系:一个健壮的系统,必然到处都是有意的 catch;异常被消化不等于问题不存在。 观测与韧性,是一个硬币的两面------降级逻辑保证系统不崩,FirstChance 保证你能看见它为什么降级。
下次当你面对一个"明明感觉有问题但日志里什么都没有"的系统------或者一个"突然变笨"的 AI Agent------不妨试试挂上这个 handler,说不定会有意外收获。
本文涉及的源码 :github.com/clawdotnet/openclaw.net(MIT 协议,NativeAOT 友好的 .NET AI Agent 运行时,感兴趣的朋友可以 star 一下)