最近我把一个跑了大半年的Agent服务完整重写了一遍,最终形态就是OpenClaw.NET:一个自托管、多模型、基于.NET NativeAOT编译的Agent平台。折腾完最大的感受是,这套组合比预想的更适合做私有化部署,但也确实踩了不少坑。这篇文章不打算写流水账,而是把核心设计、编译配置、模型接入方法以及排查记录一次讲清楚,给正在调研或打算自建Agent平台的朋友做个参考。
先说结论:如果你只想快速验证Agent效果,用托管平台确实省事;但一旦要接内部知识库、私有工具、合规审计,或者要长期控制成本,自托管几乎是必然选择。而把Agent运行时做成NativeAOT之后,单体发布、秒级冷启动、低内存占用这些特性会直接改变部署体验,不再是“解释型脚本跑起来再说”的状态。接下来按我的实际推进顺序展开。
1. 为什么要把Agent平台自托管
1.1 托管方案看着省心,问题藏在后面
我之前也试过直接调云服务商提供的Agent编排能力,刚开始很舒服,接口封装好、界面现成、调几个参数就能跑通。但真实业务跑起来后,问题开始浮出水面。
首先是数据边界。内部知识库、工单内容、代码片段一旦进入第三方服务,审计和脱敏就很被动。有些场景允许,有些场景只允许数据留在内网,那托管方案在第一轮就出局。
其次是功能锁定。托管服务通常有自己的工具调用格式、上下文管理策略和会话模型。表面上都是“让模型调用工具”,实际协议差异很大,换一家服务商几乎等于重写适配层。如果你的目标是多模型共生,而不是被一家绑死,那托管平台很难给出足够的灵活性。
还有一个常被忽略的点:限流和延迟。Agent循环本身会发起多轮模型调用,一次复杂任务可能消耗几十次请求。托管平台一旦限流,整个任务卡住,重试机制又不在自己手里,排查问题特别痛苦。
1.2 自托管之后能拿到什么
把Agent运行时收回到自己手里,第一收益是路由可控。我可以把不同任务分给不同模型:轻量任务走小模型,复杂推理走大模型,本地敏感任务走本地部署的开源模型。这个灵活性在做成本优化时非常值钱。
第二是数据主权。所有对话记录、工具调用日志、模型返回内容都留在自己的服务里,审计可以做到请求级别。合规审查时,不再需要向第三方要日志。
第三是工具接入不受限。内部系统往往有私有的API、数据库、脚本、消息队列,托管平台只给你放有限的“插件”。自托管之后,工具就是普通代码方法,安全校验、超时控制、权限模型全都可以自定义。
1.3 为什么选.NET而不是Python或Node
我承认Python在模型生态上确实方便,很多工具库都是Python优先。但Agent平台不只是“调用模型”,它还是一个常驻服务,要处理并发、连接池、配置热更新、优雅停机这些后端正事。用.NET来做,一是类型系统完整,二是性能好,三是有NativeAOT这条明显优于JIT容器的发布路径。
Node的优势是异步IO,但CPU密集场景(比如JSON Schema生成、提示词模板渲染、工具结果校验)不如.NET稳。Python则在部署时需要带上解释器和一堆依赖,若要打成单文件或独立二进制,工程复杂度不低。
.NET还有一点让我踏实:微软系的语言服务、编译期诊断和调试体验是成熟的,大规模重构的时候不会心虚。NativeAOT虽然限制多,但一旦适配完成,发布的产物真是“一个文件跑起来”,这对私有化部署价值巨大。
2. NativeAOT:挺香,但别踩坑
2.1 NativeAOT解决什么问题
NativeAOT的本质是提前把.NET IL编译为机器码,不走LLVM/JIT的运行时汇编生成路径。带来的收益非常直接:冷启动从几百毫秒甚至秒级,压到了几十毫秒;托管堆内存占用明显下降;最终产物没有运行时依赖,复制到一台裸机就能跑。
对Agent服务来说,冷启动尤其重要。因为模型调用本身已经是网络等待,如果服务端自己启动慢,无状态扩容时体验就很差。我之前在同一台机器上对比过:JIT版本进程启动约800毫秒,NativeAOT版本约30毫秒。内存方面,空载从200MB降到70MB左右。这个差距在容器调度场景里意味着更高的实例密度和更少的资源浪费。
2.2 代价:反射、动态代码、序列化
NativeAOT不是免费的。第一个限制是反射能力被大幅裁剪,运行时动态创建类型、按字符串调用方法这些玩法基本不可用。第二个限制是动态加载程序集(装插件那套模式)不支持。第三个是System.Text.Json默认的反射序列化在AOT下会挂或产生警告,必须改用源生成器。
换句话说,你在普通.NET里可以依赖“能跑就行”的动态行为,在NativeAOT里全部要在编译期交代清楚。项目初期我低估了这部分的改造量,一度想放弃AOT。熬过来之后发现,强制静态化反而把代码质量拉高了——哪些类型在哪个模块流转、序列化契约是什么,全都清清楚楚。
2.3 csproj里的关键配置
以一个典型的宿主项目为例,我会在csproj里放这样一段:
<PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <PublishAot>true</PublishAot> <InvariantGlobalization>true</InvariantGlobalization> <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault> <TrimmerSingleWarn>false</TrimmerSingleWarn> </PropertyGroup>PublishAot是总开关。InvariantGlobalization关闭全球化,避免调用ICU相关原生库,体积和兼容性都更好。JsonSerializerIsReflectionEnabledByDefault设为false后,强制所有JSON序列化都走源生成器,编译期就会告诉你哪些地方没接AOT上下文。TrimmerSingleWarn这个开关我建议先关掉,别让单条裁剪警告刷屏,但正式发布前必须逐个检查关键警告。
如果你在代码里用了正则表达式,强烈建议换成源生成模式:
[GeneratedRegex("^[a-zA-Z0-9_-]+$")] private static partial Regex SafeKeyRegex();这能避免AOT下的正则首次编译卡顿,也消除了运行时反射路径。
2.4 编译与裁剪经验
NativeAOT编译比普通.NET慢不少,第一次编译可能需要一分钟以上。我一般用dotnet publish -c Release -r linux-x64 -p:PublishAot=true做最终发布,日常开发还是用普通JIT模式跑,把AOT检查留给发布流水线。
裁剪阶段最磨人的是“编译器认为没问题,运行时偏偏缺类型”。解决办法只有一个:把所有在运行时要用的类型明确写进JSO.N序列化上下文,例如:
[JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(AgentConfig))] [JsonSerializable(typeof(List<ChatMessage>))] [JsonSerializable(typeof(ToolDefinition))] internal sealed partial class AppJsonContext : JsonSerializerContext { }然后在代码里统一通过JsonSerializer.Serialize(obj, AppJsonContext.Default.TypeMap)的方式调用。这确实比随手new JsonSerializerOptions()烦,但换来的是发布产物可控。
3. 核心架构和模块设计
3.1 Agent运行时主体
OpenClaw.NET的Agent内核是一个典型的“计划-执行-观察”循环。用户输入进来后,先把历史消息拼好,交给模型。如果模型返回工具调用,就执行对应工具,把结果作为新消息塞回去,再让模型继续。直到模型不再请求工具、给出最终回答,或者达到最大轮数。
伪代码大概是:
public async Task<AgentResult> RunAsync(AgentRequest request, CancellationToken ct) { var messages = request.History.ToList(); messages.Add(new UserMessage(request.Input)); for (int step = 0; step < request.MaxSteps; step++) { var response = await _planner.CompleteAsync(messages, _toolCatalog, ct); if (response.FinishReason == FinishReason.ToolCalls) { foreach (var toolCall in response.ToolCalls) { var result = await _toolExecutor.ExecuteAsync(toolCall, ct); messages.Add(new ToolResultMessage(toolCall.Id, result)); } continue; } if (response.FinishReason == FinishReason.Stop) { return new AgentResult(response.Text); } } return AgentResult.FromTruncated(messages); }这个循环本身不复杂,真正的复杂度在轮次管理、工具异常处理、上下文截断和并发控制。比如工具执行超过30秒要中断,模型同一轮返回多个并行工具调用时要等待全部完成,这些细节直接决定生产可用度。
3.2 模型适配层
因为目标是多模型,我在中间加了一层适配器接口:
public interface IModelProvider { string ProviderName { get; } bool SupportToolCalls { get; } ValueTask<ModelResponse> CompleteAsync(IReadOnlyList<ChatMessage> messages, ModelOptions options, CancellationToken ct); }所有上游模型都通过这个接口进来。内部消息统一用OpenClaw自己的ChatMessage模型,再由各个适配器转换成对应服务商要求的格式。这样内核不感知具体是哪个模型,切换、降级、灰度都只是在配置层改一个名字。
适配层要处理几个差异点:工具调用格式(有的是参数对象,有的是JSON字符串)、系统提示词处理方式、上下文长度上限、是否支持并发工具调用。我把这些差异都收敛到了适配器内部,内核永远只看到统一的返回结构。
3.3 工具注册与权限控制
工具是Agent能力的放大器。OpenClaw.NET里工具不通过反射动态发现,而是用静态注册表。每个工具用特性标注名称、描述、参数说明,启动时通过源生成器生成JSON Schema,这样NativeAOT下也能安全序列化。
工具本身分两类:一类是宿主进程内的纯函数,比如计算、文本处理、SQL生成;另一类是外部系统调用,比如HTTP请求、消息推送、运维脚本。后者必须有超时、重试和白名单校验。我习惯给每个工具定义一个安全等级,默认只有Public级别的工具能被所有会话调用,内部工具额外校验来源和令牌。
3.4 会话记忆和上下文管理
长对话的上下文窗口管理是所有Agent平台的痛。OpenClaw.NET采用分层策略:短会话直接把历史消息全部传给模型;中长会话用滑动窗口,只保留最近N条消息和早期摘要;超过模型上限时,触发摘要压缩。
摘要我会单独调用模型生成,不直接丢弃,而是存放在内存缓存。这样用户在会话中引用几轮之前的信息时,模型还能从摘要里捞到一些上下文。对于更长的持久化场景,后面可以接向量库,但核心对话循环不应该依赖外部检索,否则延迟和故障链路太长。
4. 部署和配置实操
4.1 编译发布命令
以Linux x64目标为例,完整发布命令是:
dotnet publish OpenClaw.NET/OpenClaw.NET.csproj \ -c Release \ -r linux-x64 \ -p:PublishAot=true \ -p:InvariantGlobalization=true \ -p:StripSymbols=true \ --self-contained \ -o ./artifacts/linux-x64StripSymbols可以减小体积,不需要生产调试符号时可以开。发布完成后,产物就是一个原生可执行文件和少量可选资源文件,把它丢到内网服务器上,chmod +x后直接跑就行。
4.2 容器镜像配置
虽然NativeAOT产物本身不需要运行时,但仍然建议打成镜像,方便统一运维。多阶段构建的思路是:编译阶段用完整SDK镜像,运行阶段只复制产物和配置文件。
一个参考写法:
FROM sdk-image:latest AS build WORKDIR /src COPY . . RUN dotnet publish OpenClaw.NET/OpenClaw.NET.csproj -c Release -r linux-x64 -p:PublishAot=true -o /out FROM runtime-base:latest COPY --from=build /out/openclaw-net /app/ COPY appsettings.json /app/ WORKDIR /app EXPOSE 8080 ENTRYPOINT ["/app/openclaw-net"]基础镜像建议选择带最小运行时依赖的发行版,AOT产物一般只需要基本的系统库。镜像里不装模型推理引擎,模型调用全部走网络,所以镜像可以保持很小。
4.3 配置与密钥管理
不要把密钥写进appsettings.json,至少要支持环境变量覆盖。我习惯在配置对象里用一个字符串占位符,比如PRIMARY_MODEL_API_KEY,然后启动时自动读取环境变量,找不到就直接启动失败并给出明确日志,避免上线后带着空密钥“装死”。
配置结构大概这样:
{ "Host": { "ListenUrls": "http://0.0.0.0:8080", "MaxRequestSize": 10485760 }, "Agent": { "DefaultModelProvider": "primary", "MaxSteps": 12, "DefaultTemperature": 0.3 }, "ModelProviders": { "primary": { "Type": "openai-compatible", "BaseUrl": "https://api.example.com/v1", "Model": "model-a", "ApiKeyEnv": "PRIMARY_MODEL_API_KEY" }, "fallback": { "Type": "local-http", "BaseUrl": "http://127.0.0.1:8081/v1", "Model": "local-model-b", "ApiKeyEnv": "LOCAL_MODEL_KEY" } } }这里的BaseUrl是举例,实际按你自己模型网关的地址填。
4.4 进程守护与健康检查
单文件应用虽然简单,但生产环境仍然需要进程守护和探活。OpenClaw.NET内置了三个HTTP端点:/health/live、/health/ready和/metrics。就绪探针会检查模型适配器连接是否可用、工具注册表是否完整、内存缓存的写入是否正常。
进程守护我直接用服务管理器配置,ExecStart指向可执行文件,然后定期curl健康端点。配合自动重启策略,实测几个星期都稳定。日志输出统一到标准输出,交给日志采集端统一处理。
5. 多模型接入实战
5.1 配置多Provider与切换
OpenClaw.NET支持在同一个进程里配置多个Provider。启动时根据配置创建连接池,但不会立刻发请求,只在模型被调用时做懒加载。这样即使某个Provider的地址暂时不可用,也不影响整个平台启动。
切换逻辑有两种:一种是在请求上指定Provider;另一种是配置一个路由策略,比如“默认走primary,超时或限流时自动切换fallback”。我在管理界面上给每个用户组绑定默认Provider,同时允许用户手动指定,这套机制在内部试用时非常实用。
5.2 模型能力差异处理
不是所有模型都支持工具调用。OpenClaw.NET的适配器会暴露三个能力位:是否支持工具调用、是否支持结构化JSON输出、最大上下文长度。内核请求模型前先检查能力,如果不支持,就把工具描述转成普通文本提示词,用“请按JSON格式输出”的方式引导。
这个降级路径很关键。因为实际环境里,同一个模型网关后面可能动态切换不同规模的模型,如果灰度到一个小参数模型,它不支持严格工具调用,平台不能崩,只能退化成“文本黑板”模式。虽然效果打折,但至少链路是通的。
5.3 自动降级与重试策略
模型接口不稳定是常态。OpenClaw.NET按照状态码和错误类型分了几类处理:
- 限流、过载、临时网络错误:自动重试,最多3次,退避时间递增。
- 鉴权失败、请求格式错误:不重试,直接返回错误给调用方。
- 超时:切换到fallback Provider,并记录一条切换日志。
这里要特别提一下超时设置。模型推理时间跟输入长度和输出长度强相关,不能用一个固定超时。我根据请求token估算 + 输出上限动态计算超时阈值,比如预估输出2048token,就设置90秒超时;只输出摘要的任务设置20秒。死板的超时常常是生产事故的源头。
5.4 成本控制与token统计
多模型接入后,成本监控必须第一时间跟上。OpenClaw.NET在请求层统一做Token计数,每次模型调用都记录输入token、输出token、Provider名称、耗时。这些数据写入内存指标缓存,再定期输出到日志。
我还会在服务端设置一个“单次任务Token预算”,比如单次任务上限4万token,超过就终止循环并提示用户精简任务。这么做不是因为技术做不到,而是成本失控往往发生在无人盯着的自动化任务上,预算墙是最有效的止痛药。
6. 常见问题与排查清单
6.1 NativeAOT编译期问题
我把最常遇到的编译问题整理成了一张表,方便直接对照。
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| IL2000警告/错误 | 裁剪器无法确定某个类型是否被使用 | 在保留配置里显式保留,或改用源生成器 |
| JSON序列化运行时报错 | 没有给对应类型加JsonSerializable | 补上[JsonSerializable]声明并统一走上下文 |
| 正则表达式运行期很慢 | 使用了普通Regex构造 | 改成[GeneratedRegex] |
| 动态加载程序集失败 | NativeAOT不支持Assembly.LoadFrom | 改为进程外插件,或把所有插件类型编译进主程序 |
| 启用AOT后体积反而异常大 | 开了多个裁剪选项冲突 | 检查TrimmerRootAssembly和PublishAot是否冗余 |
6.2 运行时我踩过的三个坑
第一个是时间格式化。因为开了InvariantGlobalization,部分区域格式化行为和开发机不同,日期输出可能变成奇怪格式。解决办法是序列化时统一使用ISO 8601格式字符串,不要依赖系统区域设置。
第二个是HttpClient的DNS刷新。默认HttpClient对同一个域名会复用连接,如果模型网关的DNS记录变了,服务不会自动感知,结果就是“一直连旧IP”。我建议设置连接生命周期,比如15分钟强制重新解析。
第三个是AOT下的单例服务误用。NativeAOT裁剪后,某些依赖注入容器的动态注册会失效。如果坚持用容器,要把所有服务显式注册。我的做法是尽量不用太重的外部容器,宿主启动代码自己创建单例对象,简单可靠。
6.3 多模型连接问题
模型接入最常见的原因是协议兼容。不同网关对stream_options、tool_choice、parallel_tool_calls这些字段的处理不一致。OpenClaw.NET在适配器里做了“配置开关”,不支持的字段直接不发送,而不是尝试统一塞给所有服务商。
其次是模型名映射。同一个Provider后面可能挂了多个模型别名,配置里如果写错名字,网关会返回“模型不存在”错误。我会在启动时调一次轻量模型列表接口做校验,如果发现配置的模型名不在列表里,就打印警告,但不阻塞启动。
6.4 性能排查
若发现Agent响应变慢,先看模型调用耗时,再看工具执行耗时,最后看序列化开销。AOT下序列化开销通常很小,但如果代码里反复调用JsonSerializer.Deserialize<动态类型>,就很可疑。此时应该检查是否有地方试图用反射读取属性,这往往破坏了AOT静态分析。
内存异常上涨通常和并发任务有关。我的经验是给Agent循环加一个全局并发信号量,限制同时进行的任务数,而不是无限放任。模型服务本身会做并发,但客户端如果也无限并发,故障恢复时会变成“鸀网风暴”。
7. 这段时间跑下来的一些心里话
OpenClaw.NET的折腾过程让我重新理解了“平台”这个词的重量。写一个能调用模型的脚本很简单,但要把它做成一个可部署、可运维、可替换模型、可审计的自托管平台,工程量是另一个量级。NativeAOT在最开始给我的反射限制和编译警告带来不少麻烦,可一旦适应静态化思维,我反而觉得少了隐藏的动态行为,线上问题少了很多。
一个很实用的建议:如果你也想做类似的东西,第一版别急着接花哨框架,先写一个纯文本循环,跑通“模型返回工具调用-执行-回填结果”这条主链路。然后加上配置化Provider,再加NativeAOT发布。顺序反了,你会两头互相折磨。
目前这个平台已经用在我的内部知识库问答、工单标签生成和巡逻式代码巡检提醒上。每次模型升级,我只改配置里的模型名和适配参数,不用改内核代码。这个能力是当初坚持做“多模型适配层”的最大回报。下一步我打算把工具执行改成更细粒度的沙箱进程,进一步缩小工具权限边界,让外部协作方也能安全地贡献工具能力。自托管Agent的路才刚开始,但方向已经很清晰了。