如果你维护过一个跑在 Windows 上的 Agent 程序,大概能体会这类项目最尴尬的地方:看起来只是“一个常驻进程”,可真要让它像 Linux 服务一样稳定、自愈、可升级,Windows 上每一环都得重新设计一遍。我最近在给团队内部的分布式调度框架 OpenClaw 补 Windows 节点,采用的技术栈是 .NET 10,目标是在 Windows 机器上跑一个长期驻留的节点进程,负责注册、心跳、资源监控、任务执行和自更新。这篇文章把整个 OpenClaw Windows Node 从零到上线的设计过程、关键代码和踩坑记录都整理出来,给准备做 Windows 驻留程序或边缘节点的同学参考。
1. 为什么 OpenClaw 的节点端偏偏选了 .NET 10
1.1 OpenClaw 节点到底是个什么东西
OpenClaw 的整体形态是控制面加节点端。控制面负责任务调度、配置下发、结果收集,它本身不直接跑业务;节点端部署到每台参与计算的 Windows 机器上,负责接受指令、执行、上报。从架构上看,节点不参与调度决策,逻辑量不大,但它同时是控制面与物理机之间的唯一触点,稳定性要求极高。节点本质上是一个长期运行的 Windows 常驻代理。
在选型阶段,摆在我们面前的可选方案不少:Go、Python、C++、C#。Go 在 Linux 节点上表现没问题,但 Windows 场景下我们要直接集成事件日志、性能计数器、PowerShell 脚本执行、MSI 打包,并且团队里已经有不少 C# 的系统管理组件可以直接复用。用 C# 写节点属于顺势而为。下面这张表是我当时做选型对比时用的,现在看看依然适用:
| 方案 | Windows 系统层集成 | 部署体积 | 内存与启动速度 | 团队维护成本 |
|---|---|---|---|---|
| C#(.NET 10 + AOT) | 原生 API,事件日志/计数器/PowerShell 都顺手 | 单文件,几十 MB | 低 / 快 | 低 |
| Go | 需要自己封 syscall,体验一般 | 很小 | 低 / 快 | 中 |
| Python | 依赖解释器环境,打包麻烦 | 大 | 高 / 慢 | 中 |
| C++ | 原生,但开发效率低 | 小 | 低 / 快 | 高 |
结论很直接:在 Windows 生态下做驻留程序,C# 综合成本最低。而且 .NET 10 这个时间点已经不是早期 .NET Core 那种“能跑但到处缺 API”的状态,Windows 专属 API 该补的补得差不多了,跨平台发布也稳定,用它做节点是顺理成章的选择。
1.2 为什么不是 .NET 8 而是 .NET 10
有人会问,.NET 8 也是 LTS,稳定性和生态都很成熟,为什么直接上 .NET 10?三个原因。
第一,.NET 10 是偶数年版本,按发布节奏属于 LTS,社区维护周期长,适合团队长期维护的项目。第二,原生 AOT 在 .NET 10 里对我们用到的 API 覆盖度已经比较完整,配合 System.Text.Json 源码生成器,可以做到无运行时依赖的单文件发布,这对部署在客户现场的 Windows 节点很重要。第三,GC 和 JIT 在 .NET 10 里针对常驻进程做了不少底层优化,节点这种 24 小时不重启的程序正好受益。
当然,这些收益不是改几行业务代码就能直观感受到的。我实测下来,AOT 发布后节点进程启动时间从原来的 300 毫秒左右降到 80 毫秒上下,内存占用比不带 AOT 的 runtime 模式低约三到四成。数字不算夸张,但节点数量放大到几百台之后,内存和启动速度的差异就很可观了。更重要的是,AOT 发布后不用在目标机器上预装 .NET 运行时,Windows 节点上能少一个全局依赖就少一个,后续排查环境问题的时候省很多事。
2. 工程骨架与依赖选型:从空文件夹到可运行节点
2.1 项目模板与 csproj 配置
新建项目我用的是 Worker Service 模板,命令行一行搞定:
dotnet new worker -n OpenClaw.NodeWorker 模板默认会生成一个 BackgroundService 和带泛型 Host 的 Program.cs,这个结构和后面接入 Windows 服务生命周期天然匹配。不过默认代码太“干净”了,我会清理掉自带的示例 Worker,只保留 Host 骨架,然后把节点需要的服务一个个注册进去。
csproj 里的关键配置是这样:
<Project Sdk="Microsoft.NET.Sdk.Worker"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0-windows</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <PublishAot>true</PublishAot> <StripSymbols>true</StripSymbols> <EnableWindowsTargeting>true</EnableWindowsTargeting> <AssemblyName>openclaw-node</AssemblyName> <RootNamespace>OpenClaw.Node</RootNamespace> </PropertyGroup> </Project>这里几个配置要单独说一下。net10.0-windows这个目标框架必须有 -windows 后缀,因为节点要用事件日志、性能计数器、DPAPI 这些 Windows 专属 API;EnableWindowsTargeting是为了允许在 Linux 构建机上交叉编译 Windows 版本,这对 CI 非常重要。我们当时的持续集成只有 Linux 环境,没有这个开关压根发布不出 win-x64 的包。PublishAot开启原生 AOT,实际发布命令是:
dotnet publish -c Release -r win-x64 -p:PublishAot=true -p:StripSymbols=true2.2 依赖越少越好
AOT 模式下,依赖库越少,踩坑的可能性就越低。很多库在普通模式下运行正常,一做 AOT 裁剪就出现反射调用失败、动态加载不兼容的问题,这种坑排查起来非常痛苦。所以我在写节点时刻意控制依赖,最终只用到了这几个包:
<ItemGroup> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" /> <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="System.Text.Json" /> <PackageReference Include="System.Diagnostics.PerformanceCounter" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" /> </ItemGroup>通信这块我没有引入 MQTT 或 gRPC 库,控制面本身提供 HTTP 接口和 WebSocket 长连接,我们自己用 .NET 内置的 HttpClient 和 ClientWebSocket 实现客户端就够用。为什么要这么克制?因为节点上每一个第三方依赖,在升级和维护时都是一个潜在的包袱。做 Agent 类程序,我的原则一直是:能少引一个包就少引一个包,不到万不得已不为了几行省事代码引入复杂依赖。
另外强烈建议打开包版本锁定文件:
<PropertyGroup> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup>打开之后会生成 packages.lock.json,后续任何依赖版本变更都会在构建时暴露差异,避免“我机器上能编,CI 上编不了”这种经典问题。
3. 注册、心跳与任务通道:节点与控制面的通信闭环
3.1 首次上线的注册流程
节点第一次启动时是“黑户”,没有 nodeId 也没有 token。注册流程分四步:
- 生成机器指纹:读注册表里的 MachineGuid,加上 CPUID 和一段固定盐,做 SHA256 后取前 32 位十六进制作为指纹。
- 把机器信息上报控制面:指纹、主机名、OS 版本、CPU 核数、总内存、Agent 版本。
- 控制面校验指纹后,返回全局唯一的 nodeId、初始 token、心跳间隔等参数。
- 节点把 nodeId 和 token 加密落盘,后续启动直接读取,不再重复注册。
机器指纹这件事看着简单,实际有讲究。只拿 MachineGuid 不够,因为换硬盘或者系统重装后 MachineGuid 会变,控制面可能把同一台物理机认成两台,任务调度就跑重了。把 CPUID 混进去之后,至少能在 MachineGuid 丢失时做交叉识别。注册失败的场景也要考虑:控制面短暂不可用不能死等,我这边做了指数退避重试,最长间隔 5 分钟一次,避免把控制面打崩。
3.2 心跳:状态同步的节拍器
心跳是节点存在的证明,也是控制面判断节点离线的主要依据。心跳间隔我设为 15 秒,理由很简单:控制面希望在 30 秒内感知到节点掉线,心跳间隔 15 秒时允许节点错过一次;再短的话,几百台节点的集群对控制面会产生不必要的并发压力。
心跳内容包含节点状态、当前执行中任务数、任务队列积压量和最近异常摘要。每次发送后等响应,如果失败就按指数退避重试:
public sealed class HeartbeatService : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var delay = TimeSpan.FromSeconds(15); var failCount = 0; while (!stoppingToken.IsCancellationRequested) { var ok = await _client.SendHeartbeatAsync(stoppingToken); if (ok) { failCount = 0; delay = TimeSpan.FromSeconds(15); } else { failCount++; var backoff = Math.Min(60, 1 * Math.Pow(2, failCount)); delay = TimeSpan.FromSeconds(backoff) + TimeSpan.FromSeconds(Random.Shared.Next(0, 5000)); } await Task.Delay(delay, stoppingToken); } } }退避公式是min(60s, 1s * 2^failCount) + 随机 0 到 5 秒抖动。抖动一定要加,否则大批节点同时断网又同时恢复,恢复后的第一次请求会像潮水一样涌向控制面。如果控制面返回 429 状态码,说明它已经过载,这时候心跳可以主动把间隔拉长到 60 秒,给控制面留出喘息时间。
3.3 任务拉取与长连接
任务通道按任务类型分了两种。短任务走 HTTP 长轮询,节点请求GET /api/v1/tasks/pending?timeout=30,控制面挂起这个请求,直到有任务或者 30 秒超时才返回;长任务和流式任务走 WebSocket,控制面可以实时推送任务开始和取消指令。
为什么不直接用固定间隔轮询?算一笔账:500 台节点,每 5 秒轮询一次,控制面每秒要处理 100 个请求,其中绝大多数是无用空转。改成 30 秒长轮询后,同样的 500 台节点,QPS 直接降到 1/6 左右,而且任务提交到节点开始执行的延迟反而更低了,因为长轮询请求挂起期间任务一到就能立即返回。这是典型的“用连接换请求”。
任务执行结果上报这块,我一开始是执行完立刻 POST,后来发现不行。控制面偶尔会处理不过来或者网络抖动,结果上报失败后任务就丢了。现在改成先写本地 spool 目录,收到控制面的确认再删;控制面接口是幂等的,重复上报同一个 taskId 不会产生副作用。这样能保证 at-least-once 语义,任务结果不丢。
4. 资源监控与执行治理:Windows 上最容易翻车的两部分
4.1 CPU/内存采样:PerformanceCounter 的正确用法
节点要向控制面上报资源状态,最简单的方案是直接用 System.Diagnostics 里的 Process 类读当前进程自己的 CPU 和内存。但节点要监控的是整机资源,不是自己这一亩三分地。Windows 上整机数据有两个来源:性能计数器和 WMI。WMI 查询慢,不适合高频采样;性能计数器快,但用起来有不少细节。
CPU 使用率我读的是Processor分类的% Processor Time,实例_Total;可用内存读的是Memory分类的Available Bytes。性能计数器的实例构造开销不小,每次 new 都会打开一次性能库句柄,所以采样器要做成单例,初始化一次,之后循环读取:
public sealed class CounterSampler : IDisposable { private readonly PerformanceCounter _cpuTotal; private readonly PerformanceCounter _memAvailable; public CounterSampler() { _cpuTotal = new PerformanceCounter("Processor", "% Processor Time", "_Total"); _cpuTotal.NextValue(); // 第一次采样只做校准,返回值不可用 _memAvailable = new PerformanceCounter("Memory", "Available Bytes", null); _memAvailable.NextValue(); } public CounterSample Read() { var cpu = _cpuTotal.NextValue(); var available = _memAvailable.RawValue; return new CounterSample(cpu, available); } public void Dispose() { _cpuTotal.Dispose(); _memAvailable.Dispose(); } }这里有个非常容易踩的坑:第一次调用NextValue()返回的永远是 0,必须等至少 1 秒后再读第二次,得到的才是真实采样值。很多人写监控模块,一上来就取第一个样本上报控制面,控制面看到 CPU 全是 0,还以为是节点出问题了。另外,某些精简版 Windows Server 可能没装完整性能库,new PerformanceCounter会直接抛异常。监控模块不能因为这类异常拖垮整个 Agent,包一层 try-catch,失败时返回空样本继续跑,比崩溃强一万倍。
4.2 任务执行:进程启动的完整生命周期管理
节点执行任务离不开Process.Start,Windows 上这套流程的坑比 Linux 多得多。我最终归纳出一个固定流程:
- 校验任务参数白名单,防止把恶意命令拼进执行串。
- 创建工作目录,重定向标准输出和标准错误。
Process.Start启动,UseShellExecute = false,CreateNoWindow = true。- 用 CancellationTokenSource 配合超时机制控制执行时长。
- 结束后读取输出和退出码,包装成任务结果上报。
一个典型的批处理脚本任务,启动命令必须是cmd.exe /c script.bat,不能直接把 FileName 设成脚本路径。PowerShell 脚本也是同理,需要powershell.exe -NoProfile -ExecutionPolicy Bypass -File script.ps1。这一点新手特别容易忽略,然后发现UseShellExecute=false时脚本根本起不来。
进程树清理是另一个大坑。直接Process.Kill()只能杀掉当前对象关联的那个进程,如果任务里又拉起了子进程,子进程就成了孤儿继续在后台跑。我后来用 Job Object 把整个进程树关进去,结束时直接终结整个 Job:
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName); [DllImport("kernel32.dll")] [return: MarshalAs(UnmanagedType.Bool)] static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); [DllImport("kernel32.dll")] [return: MarshalAs(UnmanagedType.Bool)] static extern bool TerminateJobObject(IntPtr hJob, uint uExitCode);基本流程是:创建 Job Object,设置JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,进程启动后立刻 AssignProcessToJobObject,超时或完成任务时 TerminateJobObject。这样不管进程树多深,都能一锅端。如果不想碰 P/Invoke,也可以用taskkill /PID xx /T /F,但那样会额外创建一个子进程来完成清理,逻辑上不如 Job Object 直接。
4.3 输出重定向的缓冲区陷阱
重定向标准输出的时候,如果没及时读取,子进程输出超过管道缓冲区容量就会永久阻塞。.NET 默认的管道缓冲区很小,任务日志一旦大起来,稍不注意子进程就卡死了。正确做法是启动时挂上OutputDataReceived事件,边读边写,而不是等进程退出后一次性ReadToEndAsync。
process.OutputDataReceived += (_, e) => LogSink.AppendLine(e.Data); process.ErrorDataReceived += (_, e) => LogSink.AppendLine(e.Data); process.BeginOutputReadLine(); process.BeginErrorReadLine();这样做的另一个好处是,任务执行日志可以实时落盘,就算任务中途被杀掉,也已经积累了大部分现场日志,排查问题的时候不用靠猜。
5. 凭据安全与最小权限:别让节点成为内网突破口
5.1 token 落盘:DPAPI 与 ACL 配合
节点注册后拿到的 token 是控制面的通行证,如果明文写在配置文件里,节点机器一旦被拿到,攻击者直接把配置拷走就能冒充节点。Windows 上保护这类数据最省事的办法是 DPAPI,也就是ProtectedData类。进程调用加密时不需要自己管理密钥,密钥由系统根据机器或用户范围生成。
因为我们场景里节点是 Windows 服务,运行账户可能是 LocalSystem 也可能是受管服务账户,所以用DataProtectionScope.LocalMachine范围加密,保证服务重启后还能解密:
var cipher = ProtectedData.Protect( Encoding.UTF8.GetBytes(token), null, DataProtectionScope.LocalMachine); File.WriteAllBytes(tokenPath, cipher);但 LocalMachine 范围有个副作用:本机任意进程理论上都能调用 DPAPI 解密同一段数据(管理员权限下)。所以单靠 DPAPI 不够,还必须叠加 NTFS ACL,把凭据文件所在目录的访问权限收紧,只允许服务账户和管理员访问。实际部署时我用 icacls 把C:\ProgramData\OpenClaw\目录的普通用户读取权限全部去掉,再给服务账户单独加完全控制权限。DPAPI 负责加密,ACL 负责限制访问面,两者缺一不可。
5.2 token 轮换的双缓冲
控制面签发的 token 有有效期,我这边默认 30 天轮换一次。轮换机制一开始做得很简单,收到新 token 直接覆盖旧 token,后来在测试环境踩了大坑:某次控制面切换密钥期间网络抖动,节点拿着刚收到但还没生效的新 token 去连控制面,控制面不认,节点又找不到旧 token,只能人工上机器重置。从那以后我改成双缓冲轮换:
- 心跳如果返回
nextToken字段,节点把它写入备用凭据区,不删除旧 token。 - 之后每次心跳先用新 token 尝试,成功才删旧 token。
- 新 token 验证失败就继续用旧 token,同时上报问题。
这个策略保证 token 切换期间即使发生网络分区,节点也不会把自己锁在外面。生产环境的教训就一句话:凭据切换必须有“过渡窗口”,绝对不能搞“原子替换”。
5.3 执行特权任务的凭据分离
很多任务需要管理员权限,比如装补丁、改注册表。如果直接把节点主进程放在 LocalSystem 账户下运行,等于把所有能力都暴露给攻击面最大的进程。我维护的节点现在遵循一个原则:节点主体进程只保留普通服务权限;确实需要管理员权限的任务,节点不自己执行,而是通过 Windows 计划任务(schtasks)交给独立的高权限账户去跑,节点只负责创建、启动、观察退出状态,结束后删除计划任务。
这样做的好处是,即使节点进程被攻破,攻击者拿到的也只是受限权限,没法直接改系统。控制面和节点的通信还是绕不开的,但通信密钥失效后的破坏半径被限制住了。权限分离这条,宁可实现时麻烦一点,也不能把整个节点当成一个巨大的后门。
6. 部署为 Windows 服务与自更新机制
6.1 用 Hosting.WindowsServices 接入服务生命周期
节点进程本身要注册成 Windows 服务,.NET 的Microsoft.Extensions.Hosting.WindowsServices包提供了现成支持:
var builder = Host.CreateApplicationBuilder(args); builder.Services.AddWindowsService(settings => { settings.ServiceName = "OpenClawNode"; settings.Description = "OpenClaw Windows 节点服务"; }); var host = builder.Build(); host.Run();AddWindowsService之后,Host 会监听 Windows 服务控制管理器发来的停止信号,正常处理 Stop/Shutdown。值得提醒的是,Windows 服务的工作目录默认是C:\Windows\System32,而不是 exe 所在目录。凡是代码里写了相对路径的地方,服务启动后都会指向错误的位置。我是在 Program.cs 里显式把 ContentRoot 指向 exe 目录,所有日志和配置路径都基于AppContext.BaseDirectory拼出来的,彻底避开这个问题。
6.2 服务创建与故障恢复
注册服务我习惯直接用 sc.exe,而不是写一堆 PowerShell 脚本:
sc.exe create OpenClawNode binPath= "C:\Program Files\OpenClaw\openclaw-node.exe" start= auto sc.exe failure OpenClawNode reset= 86400 actions= restart/5000/restart/10000/restart/30000注意binPath=后面必须有一个空格,这是 sc.exe 的参数格式,少个空格就直接报错。第二行配置的是服务崩溃后的恢复策略:第一次失败 5 秒后重启,第二次 10 秒,第三次 30 秒;reset= 86400表示 24 小时后失败计数清零。三个恢复间隔依次递增是有意的,避免服务崩溃后立刻重启、重启后又立刻崩的快速死循环,给事件日志留出记录时间。
单文件 AOT 发布之后,安装目录里就一个 exe,部署脚本干干净净,不需要额外跑安装器。
6.3 二进制自更新方案
对驻留程序来说,手动更新几百台机器是不现实的,自更新基本是刚需。节点自更新的流程是:
- 节点从控制面获取更新包下载地址,下载到
C:\ProgramData\OpenClaw\staging\。 - 校验更新包 SHA256 和数字签名,校验不过就放弃并上报。
- 拉起一个独立的更新程序
OpenClawUpdater.exe。 - 主进程退出,更新程序把新文件从 staging 复制到安装目录。
- 更新程序重新拉起服务,完成。
为什么不能由主进程直接替换自己的 exe?Windows 下运行中的 exe 文件是被占用的,无法覆盖,必须先退出自己,再让另一个进程做替换。更新程序要单独编译成一个小 exe,它本身很稳定,不依赖节点主进程的任何状态。另外我会保留上一版本的目录,新版本启动后如果 60 秒内连续重启超过三次,节点会回滚到app.old,至少不会让一次失败的升级把整台机器搞挂。
7. 实测踩坑与性能调优记录
7.1 句柄数悄悄涨到上万
第一次联调资源监控模块时,节点跑了大概两天,现象是内存没怎么涨,但任务管理器里句柄数从几百一路涨到上万,最终导致创建 Job Object 失败,任务全部超时。排查下来,根因在 PerformanceCounter:每次构造都会创建底层句柄,我在采样逻辑里图省事,每次采样都 new 一个 PerformanceCounter,用完又没 Dispose。数量少的时候看不出问题,节点 24 小时跑下来,句柄就全堆积了。
解决方式其实前面已经写了:采样器做成单例,进程生命周期内只初始化一次,用完统一 Dispose。监控类代码一定要在压测环境里至少跑两天再上线,单纯跑通功能根本暴露不了这类问题。
7.2 AOT 裁剪下的 JSON 反序列化崩溃
AOT 发布后,节点第一次连控制面就在 JSON 反序列化上崩了,抛的是NotSupportedException,提示 reflection-based serialization 不支持。原因是 System.Text.Json 在 AOT 模式下默认不能反射未注册的类型,必须用源码生成器。做法是定义一个 JsonSerializerContext,把所有通信模型都注册进去:
[JsonSerializable(typeof(RegisterRequest))] [JsonSerializable(typeof(RegisterResponse))] [JsonSerializable(typeof(HeartbeatRequest))] [JsonSerializable(typeof(TaskSpec))] public partial class OpenClawJsonContext : JsonSerializerContext { }所有ReadFromJsonAsync和PostAsJsonAsync都显式传入这个 context 对应的 JsonSerializerOptions。除了通信模型,任务参数里如果有自定义配置类,也要一并在 context 里注册。AOT 模式下“能编过但跑起来爆”的问题大多出在这里,尽早把所有序列化类型集中管理,能省很多排查时间。
7.3 心跳与任务执行抢线程
最早版本我把心跳和任务执行放在同一个 BackgroundService 里,任务一多,心跳就卡住了。测试环境里一个 5 分钟的长任务执行期间,控制面看这个节点一直是 offline,节点这边还以为自己活得好好的。原因很直白:同一个服务顺序执行,心跳拿不到执行权,只能排队。
后来把心跳和任务分发拆成两个独立的 HostedService,任务执行走自己的 Channel 消费线程,心跳固定 15 秒一拍谁也不耽误谁。这也是所有 Agent 类程序的一个设计原则:控制面通信和服务执行必须分开线程,任何一环都不能因为另一环的阻塞而失联。
7.4 第一次读 CPU 一定是 0
这个坑在 4.1 里提过,但值得单独记一笔。PerformanceCounter 的NextValue()第一次返回值永远不可用,这是计数器采样机制决定的——它需要前一个样本做差值计算。如果采样间隔又太短,比如小于 1 秒,读出来的 CPU 百分比经常是 0 或者高得离谱。我的做法是采样器初始化时先读一次做校准,正式采样间隔固定 5 秒,数据曲线才比较平滑。控制面的监控告警阈值也是按 5 秒采样设计的,避免因为采样粒度问题产生大量误报。
7.5 日志与磁盘 IO 的取舍
Windows 的实时文件监控服务对高频日志写入是有影响的,日志文件滚动得太频繁,节点整体 IO 会被拖起来。我现在把所有日志统一写到C:\ProgramData\OpenClaw\logs,按天滚动、保留 7 天,写日志走异步队列,攒 5 秒或者 1MB 再刷一次盘。这样做的代价是日志实时性下降一点,但对节点主流程的影响几乎为零。任务执行日志仍然实时写入独立目录,因为那种日志是排障用的,可以牺牲一点磁盘 IO。
最后分享一个我个人的习惯:Windows 节点上线前,我会先把监控模块、心跳、任务执行分别做成可独立托管的 BackgroundService,然后在一台只装了最小系统的虚拟机里先跑 48 小时,重点观察句柄数、内存曲线和服务重启次数。很多问题在虚拟机里能提前暴露。另一个小经验是:所有配置项,包括心跳间隔、任务超时、token 有效期,都做成控制面可下发的远端配置,而不是编译进代码,这样大批量调参时不用重新发版本。OpenClaw 这套节点我调整了六七轮之后,现在单节点已经可以连续运行 1 个月以上不重启,崩溃恢复也不会丢任务结果。如果你也在做类似的 Windows Agent,希望这些踩坑记录能帮你少走几步。