资讯动态

OpenClaw.NET 复盘:用 NativeAOT 打造自托管多模型 Agent 平台

发布时间:2026/10/21 12:48:44 来源:尧图企业网站定制
说实话这个项目最让我上头的时刻不是它终于把几家主流的模型接口都跑通也不是 Agent 第一次自主调对工具链而是当我看到OpenClaw.NET以 NativeAOT 方式发布后产出的那个 27MB 的单文件可执行程序在没有任何运行时依赖的裸机上跑起来的那一刻。我断断续续维护了这个自托管、多模型、面向 Agent 场景的平台大半年这篇文章我更愿意把它定位成一份项目复盘加踩坑实录而不是什么官方文档。如果你也正打算用 .NET 做 Agent 平台或者想把自己的 AI 服务用 NativeAOT 压成单文件交付这里面的思路和教训大概率能帮你少走几天弯路。1. 项目缘起为什么一个 .NET 开发者会去自建 Agent 平台1.1 现成 Agent 框架的三种不舒服市面上现成的 Agent 平台其实不少但我用下来总觉得痒痒的。第一种不舒服是托管绑架很多方案默认你的数据、会话、编排逻辑全部要往云端送本地只是充当一个瘦客户端。我这边有不少场景涉及内部文档和私有数据数据出境这一关就过不了更别说我要自己控制工具调用的权限边界。第二种不舒服是重量级那些大而全的框架部署起来动辄好几个容器数据库、消息队列、编排服务一整套拉起来本地跑个 Demo 都要等半天想拆开定制某一环的时候反而被框架绑架。第三种不舒服则是语言隔离我日常工作大半在 .NET 生态里而 AI Agent 这个领域的主流示例几乎全是 Python。虽然 Python 也不是不能用但要在现有 C# 服务里嵌入 Agent 能力时凭空多出一门语言一个运行时团队协作和维护成本都上去了。1.2 技术路线抉择C# 与 NativeAOT于是我想干脆做一个自己能完全掌控的 Agent 平台。技术栈决定用 C#倒不是意气用事而是算过账的。C# 的强类型系统在描述工具接口、消息模型、配置绑定这一套场景里有天然优势编译器就能帮你拦住一大批低级的参数错误再加上 .NET 8 对 NativeAOT 的成熟支持发布出来就是一个无运行时依赖的原生可执行文件这对自托管来说太友好了——拷过去就能跑不怕目标机器上没装 .NET。我当时的路线规划就是三个词自托管、多模型、NativeAOT。自托管保证数据和编排逻辑的自主权多模型避免被任何一家模型厂商绑死NativeAOT 提供极致的分发便利和启动性能。三个词看起来简单合在一起之后几乎所有设计决策都开始围绕它们展开。提示如果你只是想在现有项目里快速加一个能聊天的功能直接用现成 SDK 就够了完全没必要自建平台。自建平台的真正价值在于你需要编排工具调用、自定义记忆策略、控制多模型切换以及完全掌控数据的流转边界。2. 整体架构一条消息经过的完整链路2.1 核心项目结构与职责边界OpenClaw.NET 的源码结构一开始很简单后来随着功能变多逐步拆成了五个核心项目。Claw.Host是 HTTP 入口基于 ASP.NET Core Minimal API 搭建负责接收对话请求、管理 WebSocket 流式连接Claw.Agent是运行时核心负责对话状态机、工具调度和上下文窗口管理Claw.Providers放各家模型服务商的适配实现每个 Provider 都实现同一套接口Claw.Tooling负责工具注册、参数校验和调用隔离Claw.Memory则处理短期会话记忆和长期摘要。这个划分的出发点很直接Provider 只解决怎么和某个模型 API 对话的问题Tooling 只解决工具怎么被发现和调用的问题Agent 层负责把两者串起来Host 只是最外层的一个壳。OpenClaw.NET/ ├── src/ │ ├── Claw.Host // HTTP/WebSocket 入口 │ ├── Claw.Agent // Agent 运行时状态机、调度、上下文 │ ├── Claw.Providers // 多模型 Provider 适配 │ ├── Claw.Tooling // 工具注册与调用 │ └── Claw.Memory // 会话记忆我刻意不让 Provider 反向依赖 Agent 层所有依赖关系都指向一个叫Claw.Abstractions的公共抽象项目。这样做的好处是想加一个新模型服务商只需要新建一个类库引用 Abstractions实现接口然后在 Host 里注册一下即可Agent 层完全不用动。多说一句这个依赖指向抽象的原则在项目初期看起来是过度设计但当我接入到第五个模型服务商的时候我无比庆幸当初忍住没图省事。2.2 请求进来之后发生了什么一条对话消息从 HTTP 进来之后会经历一条相对固定的管线。首先 Host 层解析请求参数找到对应的会话 ID如果是新会话就初始化一个空的上下文然后把用户消息追加进消息链交给 Agent 运行时。Agent 运行时做的第一件事是检查当前上下文长度是否已经逼近模型的窗口上限如果逼近就触发 Memory 层做摘要压缩——把较早的对话压缩成一个历史摘要块替换掉原始消息。之后Agent 把完整的消息链送进当前激活的 Provider调用模型接口。如果模型返回的内容里包含工具调用指令Agent 不会直接把这个指令返回给用户而是解析出工具名和参数在 Tooling 层做参数校验和权限校验然后执行对应的本地工具把工具执行结果以工具消息的形式追加进上下文再重新调用一次模型。这个循环会一直持续到模型输出不再要求调用工具为止最终把结果返回给调用方。这里有一个工程上特别容易忽略的点工具循环必须有最大迭代次数限制。如果不设上限一个写得不严谨的工具可能会诱导模型陷入无限调用循环既浪费 token 又拖垮服务。我默认设置为 8 轮超过直接终止并把当前中间结果返回给用户同时在响应里打一个truncated标记。会话管理方面我使用内存中的会话状态存储加上 SQLite 持久化每次完整对话结束后异步刷盘这样即使进程重启也能恢复最近的会话上下文。2.3 工具注册表AOT 下的动态调用替代方案工具系统是 Agent 平台里最容易反 AOT的部分。传统做法是运行时用反射扫描程序集找到带某个特性的方法然后通过MethodInfo.Invoke动态调用。这个方案在普通 JIT 下非常好用但一旦切到 NativeAOT反射元数据被裁剪程序集扫描基本失效动态调用也会因为代码裁剪而直接崩掉。我的解决办法是彻底抛弃运行时反射改用 source generator 在编译期收集工具信息。namespace Claw.Tooling; [Tool(weather.query, 查询指定城市的天气)] public static class WeatherTool { [ToolParameter(city, 城市名, required: true)] public static string City { get; set; } ; [ToolParameter(unit, 温度单位celsius 或 fahrenheit)] public static string? Unit { get; set; } public static string Execute() { // 实际的天气查询逻辑 return $city{City}, unit{Unit ?? celsius}; } }编译器在构建时通过 source generator 扫描所有标记了[Tool]特性的方法生成一份静态的工具注册表包含工具名、描述、参数 Schema直接生成 JSON Schema省得运行时再拼和一个强类型的调用委托。NativeAOT 发布时这份注册表是编译期生成的代码不依赖任何反射元数据所以裁剪不掉。这套方案还带来一个额外好处工具参数模型的强类型绑定使得参数校验可以前移到调用入口比如city缺失时在未进入工具业务逻辑之前就能抛出带明确错误码的校验异常而不是让工具内部去判断字符串是否为空。3. NativeAOT 实战性能收益与 AOT 改造的代价3.1 NativeAOT 到底解决了什么问题如果你没接触过 .NET 的 NativeAOT可以把它理解成C# 代码在编译时直接生成目标平台的原生机器码不依赖目标机器安装 .NET 运行时也没有传统 .NET 程序启动时的 JIT 编译预热过程。原生程序的好处体现在几个地方第一是没有运行时依赖拷到哪都能跑第二是启动时间大大缩短因为没有中间语言的即时编译第三是内存占用通常会更好一些因为没有了 JIT 需要的元数据和代码缓存。对 Agent 平台这个场景来说启动时间尤其重要因为 Agent 服务经常要作为内部工具被频繁拉起如果一次冷启动要等两三秒体验会很割裂。我实测 JIT 版本冷启动大约 220msAOT 版本在同样机器上约为 24ms差了近 10 倍。AOT 的代价也很实在主要是三点构建时间变长、程序集体积变大但其实单文件比 JIT 自包含发布更小、对反射和动态代码的限制。第一点可以通过增量构建缓解第二点上AOT 反而比 JIT 自包含发布更优因为裁剪器会去掉没被引用的程序集和类型第三点是改造时最痛苦的下面单独展开。3.2 反射、序列化与动态代码AOT 改造三大关NativeAOT 默认会裁剪所有未被静态引用的代码这意味着运行时反射去查找一个类型很可能得到的是类型不存在。我的项目里遇到的第一个大坑就是 JSON 序列化。System.Text.Json 常规用法会在运行时通过反射读取属性AOT 模式下这个路径直接不可用必须使用源代码生成器。打开Claw.Host项目我为每个需要序列化的 DTO 都配置了JsonSerializable上下文using System.Text.Json.Serialization; namespace Claw.Shared; [JsonSerializable(typeof(ChatRequestDto))] [JsonSerializable(typeof(ChatResponseDto))] [JsonSerializable(typeof(StreamChunkDto))] [JsonSerializable(typeof(ToolCallDto))] internal partial class ClawJsonContext : JsonSerializerContext { }在 AOT 项目中所有JsonSerializer.Serialize/Deserialize的调用都必须强制走这个 context否则运行时直接抛NotSupportedException。这里必须养成一个习惯不要直接写JsonSerializer.Serialize(obj)而是写JsonSerializer.Serialize(obj, ClawJsonContext.Default.ChatRequestDto)。一开始我总是漏掉结果每次都是跑冒烟测试的时候崩后来我在代码评审里加了一条硬规则禁止在 Host/Agent/Tooling 三层的业务代码里裸调JsonSerializer泛型重载。第二关是配置文件绑定。默认的builder.Configuration绑定 POCO 时同样依赖反射AOT 下要引入ConfigurationBinder的 source generator。具体做法是给配置类加[UnconditionalSuppressMessage]或者直接用.GetT()的 source gen 版本——更省事的其实是尽量少用复杂的配置对象嵌套用简单的IOptionsT加上手写绑定。第三关是正则表达式如果代码里有动态构造的正则模式在 AOT 下也要用[GeneratedRegex]源生成器否则要么性能差要么直接失效。3.3 体积、启动与内存的真实数据我拿同一台 4C8G 虚拟机做过一组粗略对比JIT 版本使用自包含单文件发布AOT 版本使用PublishAottrue具体数据如下指标JIT 自包含单文件NativeAOT 单文件可执行文件体积约 89MB约 27MB冷启动到 HTTP 就绪218ms24ms空载 RSS 内存152MB81MB首次工具调用耗时340ms165ms这个表不是那种严格受控的基准测试但同一机器、同一代码、同一压测脚本趋势已经足够说明问题AOT 在启动、内存、首包延迟上全面领先。代价是发布耗时明显变长我的项目在 CI 机器上 AOT 发布一次大概要 4-5 分钟而 JIT 发布只要 30 秒。如果你们还在频繁迭代阶段建议日常用 JIT 跑只在真正出包的时候切 AOT。4. 多模型接入Provider 抽象层的平衡术4.1 统一消息模型的设计多模型支持是 OpenClaw.NET 的牌面。我设计了一套统一消息模型目标是把不同模型服务商的请求格式差异挡在 Provider 内部。核心类型是ChatMessage、ChatRequest和ChatResponse。ChatMessage包含三个要素角色User/Assistant/System/Tool、内容块集合、以及可选的工具调用列表。内容块集合一开始是字符串后来为了支持多模态改成了IReadOnlyListContentBlockContentBlock可以是文本块、图像块或者工具调用块。每次新增一个 Provider工作量就是把它家的消息结构翻译成这套统一模型再把它家的流式响应翻译回标准流。public interface IChatProvider { string Name { get; } ChatResponse CompleteAsync(ChatRequest request, CancellationToken ct); IAsyncEnumerableChatStreamChunk StreamAsync(ChatRequest request, CancellationToken ct); }接口里StreamAsync和CompleteAsync是分开的因为我发现有些模型服务商在流式接口里能透出工具调用有些只能通过非流式接口拿工具调用结果。统一抽象给的是一种能力矩阵由每个 Provider 自报能力Agent 层根据矩阵决定走哪条路径。4.2 流式输出一次一个 Token 的博弈流式输出是用户体验的生命线。我用IAsyncEnumerableChatStreamChunk做流式接口每个 chunk 里包含增量文本和可选的工具调用片段。这里有个实战细节不同模型服务商流的边界完全不同。有的服务商每个 chunk 是一个 token有的却是按句子切分还有的把工具调用 JSON 拆成一串很小的增量片段。为了让上层 Agent 逻辑不感知这些差异我在 Provider 内部做缓冲区聚合只有当当前累积的增量片段构成一个完整的 JSON 片段或一个完整句子时才向下游 emit 一个 chunk。这个聚合逻辑不算复杂但必须写单元测试因为各家 API 在边界条件下比如突然断流、空 chunk、重复 chunk的表现差异非常大。流式还有一个容易踩的坑超时处理。模型 API 在流式场景下经常出现前几个 token 很快然后中间长时间没有数据的情况如果用传统的整体超时很容易误杀正在生成的请求。我实现的方案是空闲超时每次收到 chunk 就重置计时器如果连续 35 秒没有任何数据才判定超时。这个机制救了我好几次尤其是在跑长文档生成类工具的时候。4.3 模型能力差异没有 Tool Calling 的退路接入的模型越多越能体会到模型能力参差不齐这句话。支持原生 Function Calling/Tool Calling 的模型自然是首选但一些小体量、低延迟模型只提供纯文本补全。针对这类 Provider我实现了一套提示词版工具调用协议在构造请求时把工具注册表的 JSON Schema 渲染成系统提示词的一部分要求模型以严格的 JSON 格式输出工具调用命令然后我用一个简单的 JSON 解析器解析结果。这个方案的效果依赖模型遵循指令的能力实测下来稍强的模型能做到七成以上的正确格式输出但偶尔会输出思考过程JSON混排的文本所以我加了一个提取最后一段合法 JSON的容错解析器。作为降级路径它够用了。模型类型原生 Tool Calling提示词式 Tool Calling流式支持旗舰对话模型 A支持支持支持开源部署模型 B部分支持支持支持轻量快速模型 C不支持支持部分支持纯补全模型 D不支持勉强可用不支持对于完全不具备工具调用能力的模型OpenClaw.NET 的策略是直接降级为只读对话在配置层可以指定某些 Agent 场景只允许使用这类模型避免交互过程中出现解析失败导致整个任务卡死。重试方面我实现了标准的指数退避第一次失败等 1 秒重试第二次 2 秒第三次 4 秒最多重试 4 次只有网络错误和限流错误才触发重试参数错误和鉴权失败直接抛出不需要浪费重试机会。5. 自托管部署从镜像发布到日常运维5.1 部署形态与镜像优化自托管最核心的价值就是自己的机器、自己的规则。OpenClaw.NET 支持两种主力发布形态一是 NativeAOT 单文件直接跑在裸机或虚拟机上二是打成 Docker 镜像跑在容器里。单文件形态确实方便scp 过去就能跑适合内网跳板机上快速部署。容器化则适合统一运维。得益于 AOTDockerfile 可以做得极其干净我最终用的是基于distroless的非根用户镜像FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled AS final WORKDIR /app COPY --frombuild /out/Claw.Host . EXPOSE 8080 USER nonroot ENTRYPOINT [./Claw.Host]这个镜像最终大小只有 38MB 左右对比 JIT 自包含镜像动辄 200MB 起步推送和内网分发都轻松太多。顺带一提distroless镜像里没有 shell也没有包管理器这本身就是一种攻击面收敛挺适合跑面向内部数据的 Agent 服务。5.2 数据存储、密钥与隐私边界自托管不等于裸奔密钥管理和数据隐私是两条生命线。模型 API 密钥我从不写进配置文件一律通过环境变量注入容器化部署则用 Docker Secrets 或者外部的密钥管理服务挂载。代码里只通过IOptions读取日志和异常消息里做了专门的脱敏过滤——凡是匹配到密钥字段名比如*_API_KEY、*_TOKEN的值统一替换成***。这一点必须做否则一旦模型调用超时把完整请求体打进日志密钥就裸奔了。会话数据存储用的 SQLite放在一个独立的持久化目录里容器部署时用 volume 挂载。为什么选 SQLite 而不是 PostgreSQLAgent 平台的会话查询模式非常简单基本就是按会话 ID 读一条链条记录没有复杂的关系查询SQLite 在 AOT 场景下的驱动支持也很成熟零运维。等以后如果要多人协作、共享会话再平滑迁移也来得及。隐私边界上我做了两层控制第一层是所有历史会话和记忆均存储在本地第二层是发送给模型厂商的上下文范围可控可以在配置里设置最大上下文长度和敏感字段脱敏规则比如识别出身份证、手机号模式的字符串就替换成占位符。5.3 反向代理、健康检查与日志对外提供服务时我在前面加了一层反向代理做 TLS 终结和请求转发。健康检查实现了一个/healthz端点返回组件状态和当前活跃会话数给容器编排器的探针用。这里有一个我自己踩过的小坑不要把健康检查做太重最初我让/healthz顺带查一次 SQLite 连通性结果在数据库短暂锁库的时候编排器误判实例不健康频繁重启容器。后来改成直接返回200 OK把数据库检查挪到一个单独的/readyz端点只给滚动发布前的就绪探针用。日志这块结构化日志输出为 JSON 格式方便采集到日志平台里关键请求会带上session_id和trace_id排查问题的时候能够把一次完整的工具调用链串起来。6. 实测中的意外情况与经验沉淀6.1 一份粗糙但真实的性能对比在写这篇复盘之前我临时跑了组对比同一个会话场景用户问一句各城市最近三天天气并汇总成表Agent 需要调用三次天气工具分别用 JIT 发布和 AOT 发布跑 20 轮取中位数。AOT 版本端到端耗时 3.2 秒JIT 版本 3.7 秒差的 0.5 秒基本都在启动和首次模型调用前的反射初始化上。这个差距会随着工具循环轮数增加而放大因为 AOT 下的每轮工具调用省去了反射校验和 JIT 预热路径。当然我不会吹 AOT 是性能银弹对长对话而言模型推理耗时占绝对大头AOT 优化的是 Agent 编排本身的开销而编排开销越小留给模型推理的预算就越多这在大并发场景下很实在。6.2 印象最深的三个坑第一个坑就是前面提到的裸调JsonSerializer导致 AOT 运行时不支持。这个错误在开发模式下根本不会暴露JIT 一切正常一旦PublishAot发布就炸。教训是凡是计划走 AOT 的项目从一开始就要在代码评审里强制使用JsonSerializerContext。第二个坑是 HttpClient 连接回收问题。默认的HttpClient静态单例用得越久DNS 缓存越陈旧再加上我一开始没有设置SocketsHttpHandler的连接生命周期导致模型服务商换了 IP 之后服务还在往旧连接上发请求超时率陡然上升。后来我把连接生命周期设置为 5 分钟并启用了自动重定向关闭和连接池预清理。第三个坑是 source generator 的增量构建缓存。有几次修改[Tool]特性定义后重新编译出来的注册表还是旧版我一度以为是生成器没生效后来发现是 IDE 的构建缓存导致的执行dotnet build --no-incremental就能解决。习惯好之后凡是动了生成器相关代码我都主动走全量重建避免怀疑人生。问题根因解决方案AOT 下 JSON 序列化崩溃反射序列化被裁剪改用JsonSerializerContext源生成HTTP 请求后大量超时HttpClient DNS 缓存与连接复用设置PooledConnectionLifetime与周期性刷新工具注册表未更新增量构建缓存修改生成器代码后全量重建6.3 下一步在做什么OpenClaw.NET 目前还在持续演进。最近在做的几件事一是把 Memory 层从简单的摘要压缩升级为分层记忆短期会话用窗口长期记忆用向量检索这样 Agent 能在不撑爆上下文的前提下回忆几周前的对话二是给工具调用加更细粒度的 RBAC 权限控制做到工具维度-用户维度-会话维度三层的调用权限三是研究把部分工具卸载到独立的进程沙箱里执行避免一个恶意或写崩的工具拖垮整个 Host 进程。最后分享一个我在这个项目里体会最深的一点如果你决定走 NativeAOT 路线最好在项目的第一天就打开PublishAot哪怕那时候只是输出一个 Hello World。AOT 更像是一个技术约束它逼着你在早期就避开反射依赖、动态代码生成这些坑而不是等代码写完了再大规模返工。我是在项目进行到一半才切换过去的那两周的改造强度让我至今记忆犹新。如果你准备动手做自己的自托管多模型 Agent 平台我的建议只有一句话先把 AOT 跑通再谈功能堆叠那会让你后面的每一步都走得更稳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑