[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"consumer-news-detail-929":3,"consumer-news-interaction-929":40,"consumer-news-related-929":43},{"detail":4,"item":35},{"card":5,"schemaVersion":22,"fields":23,"content":29},{"id":6,"kind":7,"targetType":8,"targetId":9,"subtype":7,"typeLabel":10,"title":11,"subtitle":12,"summary":13,"coverUrl":14,"badgeText":14,"href":15,"sourceName":12,"meta":16,"metrics":19,"tags":20,"resolved":21},"NEWS_ARTICLE:929","news","NEWS_ARTICLE",929,"资讯",".NET 异常处理的\"暗门\"：代码里写满 catch，你依然能抓住它——从一个 AI Agent 运行时的源码说起","博客园","一个健壮的系统，必然到处都是有意的 catch；异常被消化不等于问题不存在。 观测与韧性，是一个硬币的两面——降级逻辑保证系统不崩，FirstChance 保证你能看见它为什么降级。","","\u002Fnews\u002F929",[17,18],"2026","软件开发",{},[18],true,"consumer-content-detail-v1",{"sourceName":12,"authorName":24,"categoryName":18,"summary":13,"description":13,"publishTime":25,"updateTime":26,"sourceUrl":27,"language":28},"张善友","2026-09-11T13:40","2026-09-11T15:21:59","https:\u002F\u002Fwww.cnblogs.com\u002Fshanyou\u002Fp\u002F22814535","中文",{"format":30,"policy":31,"normalized":21,"html":32,"text":33,"wordCount":34,"hasBody":21},"HTML","NEWS_CONTENT_V1","\u003Cblockquote>\n \u003Cp>你的系统里是不是也有这样的代码？\u003C\u002Fp>\n \u003Cpre>\u003Ccode>try { DoSomething(); }\ncatch (Exception) { \u002F* 啥也不干，异常？不存在的 *\u002F }\n\u003C\u002Fcode>\u003C\u002Fpre>\n \u003Cp>异常被吞掉了，日志里没有，监控看不到，问题却真实存在。今天介绍一个 .NET 的\"隐藏技能\"，让你\u003Cstrong>无论异常是否被 catch，都能在第一时间感知到\u003C\u002Fstrong>。\u003C\u002Fp>\n \u003Cp>为了不说空话，这次我们直接打开一个真实的开源项目——\u003Cstrong>OpenClaw.NET\u003C\u002Fstrong>（一个 NativeAOT 友好的 .NET AI Agent 运行时与网关，GitHub 搜 \u003Ccode>clawdotnet\u002Fopenclaw.net\u003C\u002Fcode>），看看它的源码里藏着多少\"被优雅吞掉\"的异常，以及我们如何用 \u003Ccode>FirstChanceException\u003C\u002Fcode> 把它们全部揪出来。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Chr>\n\u003Ch2>〇、先看源码：Agent 运行时里，异常都去哪了？\u003C\u002Fh2>\n\u003Cp>跑过 AI Agent 的朋友可能有这种体验：Agent 突然\"变笨了\"——工具调用失败它不说，记忆检索挂了她也不提，只是回答质量肉眼可见地下降。\u003C\u002Fp>\n\u003Cp>这不是玄学。打开 OpenClaw.NET 的源码，你会发现这是一个\u003Cstrong>刻意设计\u003C\u002Fstrong>的结果：Agent 运行时为了保证对话不中断，会在各个层面把异常\"降级\"掉。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>形态一：工具执行失败 → 变成一句字符串\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>\u003Ccode>src\u002FOpenClaw.Agent\u002FOpenClawToolExecutor.cs\u003C\u002Fcode> 中，工具执行用一个 catch-all 兜底：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>catch (Exception ex)\n{\n    failureCode = ClassifyToolFailureCode(tool, ex.Message);\n    failureMessage = ex.Message;\n    toolFailed = true;\n    \u002F\u002F ……部分已知故障类型走 Blocked 分支……\n    else\n    {\n        result = \"Error: Tool execution failed.\";   \u002F\u002F ← 异常被压缩成一句话\n        resultStatus = ToolResultStatuses.Failed;\n    }\n    _metrics?.IncrementToolFailures();\n    _logger?.LogWarning(ex, \"[{CorrelationId}] Tool {Tool} failed\", ...);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>异常对象在这里被\"翻译\"成 \u003Ccode>Error: Tool execution failed.\u003C\u002Fcode> 喂回给大模型。对话不会断，但\u003Cstrong>真实的堆栈只有日志里才有\u003C\u002Fstrong>——如果日志级别没配对，它就消失了。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>形态二：MCP 工具失败 → 同样变成字符串\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>\u003Ccode>src\u002FOpenClaw.Agent\u002FTools\u002FMcpNativeTool.cs\u003C\u002Fcode>：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>catch (Exception ex)\n{\n    return $\"Error: MCP tool '{localName}' failed: {ex.Message}\";\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>形态三：记忆召回失败 → 打个 Warning 继续跑\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>\u003Ccode>src\u002FOpenClaw.Agent\u002FAgentRuntime.cs\u003C\u002Fcode> 里大量这样的代码：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>catch (Exception ex)\n{\n    _logger?.LogWarning(ex, \"Memory recall injection failed; continuing without recall.\");\n    return false;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>记忆召回失败，Agent 只是\"失去记忆\"继续回答。功能上不崩，效果上降级——这就是典型的\"静默异常\"。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>形态四：LLM 调用重试耗尽 → 一句客套话\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode>catch (Exception ex) when (IsExpectedLlmFailure(ex))\n{\n    _metrics?.IncrementLlmErrors();\n    _logger?.LogError(ex, \"[{CorrelationId}] LLM call failed after all retries and fallbacks\", ...);\n    return AgentTurnResult.Completed(\n        \"Sorry, I'm having trouble reaching my AI provider right now. Please try again shortly.\");\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>平心而论，这些写法\u003Cstrong>没有问题\u003C\u002Fstrong>——Agent 运行时必须韧性优先，不能因为一个工具报错就把整个对话炸掉。但代价是：异常被层层包装、降级、消化之后，\u003Cstrong>排障时你看到的只是\"Agent 不太好使\"这个结果，而不是原因\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp>有没有办法在不改这些代码的前提下，看到每一个异常\"出生\"的瞬间？\u003C\u002Fp>\n\u003Cp>有。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>一、异常处理的两个阶段\u003C\u002Fh2>\n\u003Cp>.NET 的异常处理机制，实际上分为\u003Cstrong>两个阶段\u003C\u002Fstrong>：\u003C\u002Fp>\n\u003Ch3>阶段一：First Chance Exception（第一现场）\u003C\u002Fh3>\n\u003Cp>异常刚刚被 \u003Ccode>throw\u003C\u002Fcode> 的那一瞬间，CLR 会先触发一个\"通知事件\"——此时异常\u003Cstrong>还没有\u003C\u002Fstrong>被任何 \u003Ccode>catch\u003C\u002Fcode> 块处理。你可以把它理解为\"案发现场的第一时间报警\"。\u003C\u002Fp>\n\u003Ch3>阶段二：Stack Walking（栈回溯）\u003C\u002Fh3>\n\u003Cp>CLR 从当前栈帧开始向上遍历，寻找匹配的 \u003Ccode>catch\u003C\u002Fcode> 处理器。如果找到了（比如 OpenClaw.NET 里那些降级 catch），异常被\"消化\"；如果没找到，最终演变为未处理异常，进程可能终止。\u003C\u002Fp>\n\u003Cpre>\u003Ccode>异常抛出\n    ↓\n【FirstChanceException 触发】← 你在这里可以观测，但不能阻止\n    ↓\nCLR 开始 Stack Walking，寻找 catch 块\n    ↓\n找到 catch → 执行 → 异常消失（被降级、被吞掉、被转成字符串）\n未找到 catch → UnhandledException → 进程终止\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Chr>\n\u003Ch2>二、代码实战：挂一个全局\"监听者\"\u003C\u002Fh2>\n\u003Cp>.NET 提供了 \u003Ccode>AppDomain.FirstChanceException\u003C\u002Fcode> 事件，注册方式极其简单：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>using System;\nusing System.Runtime.ExceptionServices;\n\nclass Program\n{\n    static void Main()\n    {\n        \u002F\u002F 注册第一现场监听器\n        AppDomain.CurrentDomain.FirstChanceException += (_, e) =&gt;\n        {\n            Console.WriteLine($\"[FirstChance] 捕获到异常：{e.Exception.Message}\");\n        };\n\n        try\n        {\n            throw new Exception(\"这是一个测试异常\");\n        }\n        catch (Exception ex)\n        {\n            Console.WriteLine($\"[Catch 块] 异常被处理了：{ex.Message}\");\n        }\n\n        Console.WriteLine(\"程序正常结束\");\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>输出结果：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode>[FirstChance] 捕获到异常：这是一个测试异常\n[Catch 块] 异常被处理了：这是一个测试异常\n程序正常结束\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>看到了吗？即使异常在 \u003Ccode>try-catch\u003C\u002Fcode> 里被\"完美处理\"了，\u003Ccode>FirstChanceException\u003C\u002Fcode> 依然能\u003Cstrong>提前感知\u003C\u002Fstrong>到它。\u003C\u002Fp>\n\u003Cp>把它放到 OpenClaw.NET 的语境里：在网关启动处挂上这个 handler，那么无论是 MCP 工具超时、记忆召回失败、还是 LLM 重试过程中的每一次中间失败——\u003Cstrong>只要异常被 throw 过，你都能看到它的原始形态\u003C\u002Fstrong>，而不是被降级后的那句 \"Error: Tool execution failed.\"。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>三、重要：你不能在这里\"吞掉\"异常\u003C\u002Fh2>\n\u003Cp>很多开发者第一次用时会误以为可以在这里拦截异常，这是\u003Cstrong>错误的\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cpre>\u003Ccode>\u002F\u002F 错误示范：试图阻止异常传播\nAppDomain.CurrentDomain.FirstChanceException += (_, e) =&gt;\n{\n    \u002F\u002F e.Exception 是只读的，你无法修改或清除它\n    \u002F\u002F 异常会继续向上传播，不受你控制\n};\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>\u003Ccode>FirstChanceException\u003C\u002Fcode> 的定位是\"观测\"，不是\"处理\"。\u003C\u002Fstrong> 你可以记录日志、发送告警、统计指标，但不能阻止异常去找它的 \u003Ccode>catch\u003C\u002Fcode> 块。\u003C\u002Fp>\n\u003Cp>这一点在 Agent 场景下尤其要想清楚：OpenClaw.NET 那些降级 catch 是\u003Cstrong>有意为之的韧性设计\u003C\u002Fstrong>，你观测归观测，别想着绕过它们。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>四、边界情况一览\u003C\u002Fh2>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>场景\u003C\u002Fth>\n   \u003Cth>FirstChance 是否触发\u003C\u002Fth>\n   \u003Cth>备注\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>异常被 catch 并吞掉\u003C\u002Ftd>\n   \u003Ctd>✅ 触发\u003C\u002Ftd>\n   \u003Ctd>这正是它的核心价值\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>throw;\u003C\u002Fcode>（保留栈的重新抛出）\u003C\u002Ftd>\n   \u003Ctd>✅ 再次触发\u003C\u002Ftd>\n   \u003Ctd>会走完整的新一轮流程\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>throw ex;\u003C\u002Fcode>（破坏栈的重新抛出）\u003C\u002Ftd>\n   \u003Ctd>✅ 触发\u003C\u002Ftd>\n   \u003Ctd>但 stack trace 会被重置\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>ThreadAbortException\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>✅ 触发\u003C\u002Ftd>\n   \u003Ctd>通常不建议特别处理\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>损坏进程状态异常（如 \u003Ccode>AccessViolationException\u003C\u002Fcode>）\u003C\u002Ftd>\n   \u003Ctd>⚠️ 视版本而定\u003C\u002Ftd>\n   \u003Ctd>.NET 4+ 默认不触发，需特殊配置\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>注意第二行：OpenClaw.NET 的 \u003Ccode>AgentToolCallLoop\u003C\u002Fcode> 里并行执行多个工具调用时，任何一个工具崩了会 \u003Ccode>linkedCts.Cancel(); throw;\u003C\u002Fcode> 取消其他兄弟任务再重新抛出——\u003Cstrong>重抛会再次触发 FirstChance\u003C\u002Fstrong>，所以你在日志里可能看到同一个异常出现多次，这是正常现象，别误判成\"异常发生了两次\"。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>五、生产环境使用注意事项\u003C\u002Fh2>\n\u003Ch3>1. 防止递归死循环\u003C\u002Fh3>\n\u003Cp>如果你在 FirstChance handler 里自己的代码又抛异常了，会\u003Cstrong>无限递归\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cpre>\u003Ccode>AppDomain.CurrentDomain.FirstChanceException += (_, e) =&gt;\n{\n    \u002F\u002F ✅ 加上防护，防止日志代码自身异常导致递归\n    if (e.Exception.StackTrace?.Contains(\"MyLogger\") == true)\n        return;\n\n    \u002F\u002F 安全地记录日志...\n    Logger.Warn(\"FirstChance 异常捕获\", e.Exception);\n};\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>2. 性能开销\u003C\u002Fh3>\n\u003Cp>每次异常抛出都会触发这个事件。\u003Cstrong>异常抛出的成本本身就不低\u003C\u002Fstrong>，加上 handler 的执行，频繁异常会严重影响性能。\u003C\u002Fp>\n\u003Cp>这在 Agent 运行时里是个现实问题：一个陷入\"调用失败 → 重试 → 再失败\"循环的工具，一轮对话可能抛出几十上百次异常。建议配合\u003Cstrong>采样或限流\u003C\u002Fstrong>策略使用——比如按异常类型 + 工具名做聚合，每分钟最多上报 N 条，而不是每条都写日志。\u003C\u002Fp>\n\u003Ch3>3. 与 \u003Ccode>UnhandledException\u003C\u002Fcode> 的区别\u003C\u002Fh3>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>特性\u003C\u002Fth>\n   \u003Cth>\u003Ccode>FirstChanceException\u003C\u002Fcode>\u003C\u002Fth>\n   \u003Cth>\u003Ccode>UnhandledException\u003C\u002Fcode>\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>触发时机\u003C\u002Ftd>\n   \u003Ctd>每次异常抛出时\u003C\u002Ftd>\n   \u003Ctd>异常即将导致进程终止时\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>能否阻止进程崩溃\u003C\u002Ftd>\n   \u003Ctd>不能\u003C\u002Ftd>\n   \u003Ctd>有限（视 \u003Ccode>IsTerminating\u003C\u002Fcode>）\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>异常是否已被 catch\u003C\u002Ftd>\n   \u003Ctd>尚未确定\u003C\u002Ftd>\n   \u003Ctd>已确定没有 catch\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>用途\u003C\u002Ftd>\n   \u003Ctd>观测\u002F监控\u002F诊断\u003C\u002Ftd>\n   \u003Ctd>临终遗言\u002F兜底处理\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>OpenClaw.NET 这类有完善降级机制的运行时，\u003Ccode>UnhandledException\u003C\u002Fcode> 可能很久都不会响一次——但这不代表系统健康，只代表\u003Cstrong>异常都被消化了\u003C\u002Fstrong>。想看真实水位，得靠 FirstChance。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>六、实际应用场景\u003C\u002Fh2>\n\u003Ch3>场景 1：根治\"静默异常\"\u003C\u002Fh3>\n\u003Cp>项目中有些老代码喜欢这样写：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>try { CallThirdPartyApi(); }\ncatch { \u002F* 静默失败，以为很优雅 *\u002F }\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>上线后接口经常超时，但没有任何日志。挂上 \u003Ccode>FirstChanceException\u003C\u002Fcode> 后，所有被吞掉的异常都无所遁形。\u003C\u002Fp>\n\u003Ch3>场景 2：诊断 Agent 的\"悄悄降级\"\u003C\u002Fh3>\n\u003Cp>这就是 OpenClaw.NET 给我们展示的典型场景：Agent 回答质量下降，但没有错误日志。挂上 FirstChance 后你可能会发现——原来是记忆召回每次都在抛超时异常然后 \u003Ccode>continuing without recall\u003C\u002Fcode>，Agent 其实一直在\"失忆\"状态下工作。降级的路径是设计好的，但\u003Cstrong>降级发生的频率和原因\u003C\u002Fstrong>，只有观测了才知道。\u003C\u002Fp>\n\u003Ch3>场景 3：全链路异常监控\u003C\u002Fh3>\n\u003Cp>配合 APM 工具（如 SkyWalking、Elastic APM），在 FirstChance 中打上标记，构建\u003Cstrong>异常热力图\u003C\u002Fstrong>，哪怕异常被内部消化了，也能知道哪里是\"异常高发区\"。\u003C\u002Fp>\n\u003Ch3>场景 4：诊断偶发 Bug\u003C\u002Fh3>\n\u003Cp>某些难以复现的问题，往往是因为异常在某个深层库被 catch 后走了降级逻辑。通过 FirstChance 日志，你能看到\u003Cstrong>异常原本长什么样\u003C\u002Fstrong>，而不是被包装后的版本——比如不是 \"Error: Tool execution failed.\"，而是底层的 \u003Ccode>HttpRequestException: Connection refused\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>七、总结\u003C\u002Fh2>\n\u003Cp>\u003Ccode>AppDomain.FirstChanceException\u003C\u002Fcode> 是 .NET 提供给我们的一个\u003Cstrong>\"上帝视角\"\u003C\u002Fstrong>——它不参与异常处理决策，但让你有机会看到每一个异常诞生的瞬间。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>记住三个关键点：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Col>\n \u003Cli>它能让你观测到\u003Cstrong>所有\u003C\u002Fstrong>异常，无论是否被 catch\u003C\u002Fli>\n \u003Cli>它\u003Cstrong>不能\u003C\u002Fstrong>阻止异常传播或吞掉异常\u003C\u002Fli>\n \u003Cli>生产环境使用要注意\u003Cstrong>递归防护\u003C\u002Fstrong>和\u003Cstrong>性能影响\u003C\u002Fstrong>\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>而 OpenClaw.NET 的源码则给了我们一个很好的参照系：\u003Cstrong>一个健壮的系统，必然到处都是有意的 catch；异常被消化不等于问题不存在。\u003C\u002Fstrong> 观测与韧性，是一个硬币的两面——降级逻辑保证系统不崩，FirstChance 保证你能看见它为什么降级。\u003C\u002Fp>\n\u003Cp>下次当你面对一个\"明明感觉有问题但日志里什么都没有\"的系统——或者一个\"突然变笨\"的 AI Agent——不妨试试挂上这个 handler，说不定会有意外收获。\u003C\u002Fp>\n\u003Chr>\n\u003Cblockquote>\n \u003Cp>\u003Cstrong>本文涉及的源码\u003C\u002Fstrong>：\u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fclawdotnet\u002Fopenclaw.net\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">github.com\u002Fclawdotnet\u002Fopenclaw.net\u003C\u002Fa>（MIT 协议，NativeAOT 友好的 .NET AI Agent 运行时，感兴趣的朋友可以 star 一下）\u003C\u002Fp>\n\u003C\u002Fblockquote>","你的系统里是不是也有这样的代码？ try { DoSomething(); } catch (Exception) { \u002F* 啥也不干，异常？不存在的 *\u002F } 异常被吞掉了，日志里没有，监控看不到，问题却真实存在。今天介绍一个 .NET 的\"隐藏技能\"，让你无论异常是否被 catch，都能在第一时间感知到。 为了不说空话，这次我们直接打开一个真实的开源项目——OpenClaw.NET（一个 NativeAOT 友好的 .NET AI Agent 运行时与网关，GitHub 搜 clawdotnet\u002Fopenclaw.net），看看它的源码里藏着多少\"被优雅吞掉\"的异常，以及我们如何用 FirstChanceException 把它们全部揪出来。 〇、先看源码：Agent 运行时里，异常都去哪了？ 跑过 AI Agent 的朋友可能有这种体验：Agent 突然\"变笨了\"——工具调用失败它不说，记忆检索挂了她也不提，只是回答质量肉眼可见地下降。 这不是玄学。打开 OpenClaw.NET 的源码，你会发现这是一个刻意设计的结果：Agent 运行时为了保证对话不中断，会在各个层面把异常\"降级\"掉。 形态一：工具执行失败 → 变成一句字符串 src\u002FOpenClaw.Agent\u002FOpenClawToolExecutor.cs 中，工具执行用一个 catch-all 兜底： catch (Exception ex) { failureCode = ClassifyToolFailureCode(tool, ex.Message); failureMessage = ex.Message; toolFailed = true; \u002F\u002F ……部分已知故障类型走 Blocked 分支…… else { result = \"Error: Tool execution failed.\"; \u002F\u002F ← 异常被压缩成一句话 resultStatus = ToolResultStatuses.Failed; } _metrics?.IncrementToolFailures(); _logger?.LogWarning(ex, \"[{CorrelationId}] Tool {Tool} failed\", ...); } 异常对象在这里被\"翻译\"成 Error: Tool execution failed. 喂回给大模型。对话不会断，但真实的堆栈只有日志里才有——如果日志级别没配对，它就消失了。 形态二：MCP 工具失败 → 同样变成字符串 src\u002FOpenClaw.Agent\u002FTools\u002FMcpNativeTool.cs： catch (Exception ex) { return $\"Error: MCP tool '{localName}' failed: {ex.Message}\"; } 形态三：记忆召回失败 → 打个 Warning 继续跑 src\u002FOpenClaw.Agent\u002FAgentRuntime.cs 里大量这样的代码： catch (Exception ex) { _logger?.LogWarning(ex, \"Memory recall injection failed; continuing without recall.\"); return false; } 记忆召回失败，Agent 只是\"失去记忆\"继续回答。功能上不崩，效果上降级——这就是典型的\"静默异常\"。 形态四：LLM 调用重试耗尽 → 一句客套话 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 事件，注册方式极其简单： using System; using System.Runtime.ExceptionServices; class Program { static void Main() { \u002F\u002F 注册第一现场监听器 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.\"。 三、重要：你不能在这里\"吞掉\"异常 很多开发者第一次用时会误以为可以在这里拦截异常，这是错误的。 \u002F\u002F 错误示范：试图阻止异常传播 AppDomain.CurrentDomain.FirstChanceException += (_, e) => { \u002F\u002F e.Exception 是只读的，你无法修改或清除它 \u002F\u002F 异常会继续向上传播，不受你控制 }; 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 里自己的代码又抛异常了，会无限递归。 AppDomain.CurrentDomain.FirstChanceException += (_, e) => { \u002F\u002F ✅ 加上防护，防止日志代码自身异常导致递归 if (e.Exception.StackTrace?.Contains(\"MyLogger\") == true) return; \u002F\u002F 安全地记录日志... Logger.Warn(\"FirstChance 异常捕获\", e.Exception); }; 2. 性能开销 每次异常抛出都会触发这个事件。异常抛出的成本本身就不低，加上 handler 的执行，频繁异常会严重影响性能。 这在 Agent 运行时里是个现实问题：一个陷入\"调用失败 → 重试 → 再失败\"循环的工具，一轮对话可能抛出几十上百次异常。建议配合采样或限流策略使用——比如按异常类型 + 工具名做聚合，每分钟最多上报 N 条，而不是每条都写日志。 3. 与 UnhandledException 的区别 特性 FirstChanceException UnhandledException 触发时机 每次异常抛出时 异常即将导致进程终止时 能否阻止进程崩溃 不能 有限（视 IsTerminating） 异常是否已被 catch 尚未确定 已确定没有 catch 用途 观测\u002F监控\u002F诊断 临终遗言\u002F兜底处理 OpenClaw.NET 这类有完善降级机制的运行时，UnhandledException 可能很久都不会响一次——但这不代表系统健康，只代表异常都被消化了。想看真实水位，得靠 FirstChance。 六、实际应用场景 场景 1：根治\"静默异常\" 项目中有些老代码喜欢这样写： try { CallThirdPartyApi(); } catch { \u002F* 静默失败，以为很优雅 *\u002F } 上线后接口经常超时，但没有任何日志。挂上 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\u002Fclawdotnet\u002Fopenclaw.net（MIT 协议，NativeAOT 友好的 .NET AI Agent 运行时，感兴趣的朋友可以 star 一下）",5168,{"id":6,"kind":7,"title":11,"summary":13,"image":14,"href":15,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":39},"2026 · 软件开发","#2563eb","16 \u002F 10",[18],{"targetType":8,"targetId":9,"likedByMe":41,"likeCount":42,"commentCount":42,"contentLikeCount":42,"contentCommentCount":42,"sourceLikeCount":42,"sourceCommentCount":42},false,0,[44,51,58,65,72,78,84,91],{"id":45,"kind":7,"title":46,"summary":47,"image":48,"href":49,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":50},"NEWS_ARTICLE:927","HelloCrab - 短视频开源爬虫，仅供学习参考","HelloCrab 基于 Avalonia、Playwright、AI与 FFmpeg 的跨平台桌面采集器，支持9大平台，以及 Android、iOS、Browser 远程控制端。 Made By ChatGPT &amp; Vincent with ❤ 平台 是否接入 哔哩哔哩 ✅ 抖音 ✅ 快手","https:\u002F\u002Fwww.cnblogs.com\u002Fhupo376787\u002Fp\u002FScreenshot\u002FWindows.jpg","\u002Fnews\u002F927",[18],{"id":52,"kind":7,"title":53,"summary":54,"image":55,"href":56,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":57},"NEWS_ARTICLE:933","写给 C++ 工程师的 OpenClaw.NET 上手指南：用你熟悉的 C++ 思维，跑起一个生产级 AI Agent","它像一个「基于 boost.asio + REST 端点的常驻服务」——对外是 HTTP \u002F WebSocket \u002F 各 IM 的 webhook，对内跑着一个能调工具、读写记忆、跨渠道对话的 AI Agent——只不过协程不用你手写 promise_type，内存不用你管 new\u002Fdelete。","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F510\u002F202609\u002F510-20260905070811424-1837144874.jpg","\u002Fnews\u002F933",[18],{"id":59,"kind":7,"title":60,"summary":61,"image":62,"href":63,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":64},"NEWS_ARTICLE:952",".NET 11 RC1 发布：拿到\"准生证\"，生产环境可以上了！","从 Preview 1 到 RC1，.NET 11 的拼图基本完整了：Runtime Async 反超状态机模型、CoreCLR 登陆 WebAssembly、CLI 全面 NativeAOT 化（dotnet tool list 快 5.5 倍）、C# 15 补齐 union 和 labeled","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F510\u002F202609\u002F510-20260909153805786-850851443.png","\u002Fnews\u002F952",[18],{"id":66,"kind":7,"title":67,"summary":68,"image":69,"href":70,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":71},"NEWS_ARTICLE:961","从零搭建ELK日志采集系统：Filebeat + Logstash + ES + Kibana 保姆级教程","轻量级架构，一份配置全搞定 一、前言 你是不是还在用 tail -f 和 grep 在多台服务器上翻日志？系统一出问题，就要登录三五台机器，来回切换窗口，定位一个Bug耗时半小时。 ELK 是业界最成熟的日志集中管理方案。本文将带你用 Docker Compose 一键部署 Filebeat + L","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F1465907\u002F202609\u002F1465907-20260909162947190-195439361.png","\u002Fnews\u002F961",[18],{"id":73,"kind":7,"title":74,"summary":75,"image":55,"href":76,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":77},"NEWS_ARTICLE:980","写给 Rust 工程师的 OpenClaw.NET 上手指南：用你熟悉的 Rust 思维，跑起一个生产级 AI Agent","它像一个「axum 应用」——对外是 HTTP \u002F WebSocket \u002F 各 IM 的 webhook，对内跑着一个能调工具、读写记忆、跨渠道对话的 AI Agent——只不过 async 不用选 runtime，也不用和借用检查器格斗。","\u002Fnews\u002F980",[18],{"id":79,"kind":7,"title":80,"summary":81,"image":14,"href":82,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":83},"NEWS_ARTICLE:999","Vane.Dispatch 1.0.0 发布：一个与容器、传输层零耦合的 .NET 服务分发引擎","Vane.Dispatch 1.0.0 发布：一个与容器、传输层零耦合的 .NET 服务分发引擎 它的前身，可以追溯到一个叫 Ndf 的项目。从那时算起，已经过去了十多年。今天，它以 Vane.Dispatch 1.0.0 的名字正式发布。 序：十年磨一剑 做后端的人大抵都绕不开同一个问题：如何把&","\u002Fnews\u002F999",[18],{"id":85,"kind":7,"title":86,"summary":87,"image":88,"href":89,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":90},"NEWS_ARTICLE:1002","从对标 Java 到对标 Go：Native AOT 的\"无痛化\"之路，走到哪一站了？","从 .NET 7 的 demo 到 .NET 12 的无痛化，这条路要走五年。慢吗？慢。但对比一下：Java 的 GraalVM Native Image 折腾了更久，至今 Spring 生态的 AOT 体验仍在打补丁；Go 则是天生就站在终点线上——.NET 是在背着二十年的反射遗产追赶一个轻装上","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F510\u002F202609\u002F510-20260907133134585-1733425989.png","\u002Fnews\u002F1002",[18],{"id":92,"kind":7,"title":93,"summary":94,"image":14,"href":95,"meta":36,"badge":10,"author":12,"stats":-1,"accent":37,"coverRatio":38,"tags":96},"NEWS_ARTICLE:1012","java 多线程开发系列之八：玩转多线程（线程的中断）","之前的线程协作，讲的是通过wait和notify方法，多线程之间进行互相条件唤醒的办法。除此之外我们还需要进行中断操作。等待和唤醒可参考前文https:\u002F\u002Fwww.cnblogs.com\u002Fjilodream\u002Fp\u002F22770136设想这样一个场景：长工在给地主家干活，日出而作，日落而息。思路很简单：","\u002Fnews\u002F1012",[18]]