自托管、编译产物是单文件原生二进制(跟
go build一样)、自带鉴权/记忆/多渠道接入的 .NET AI Agent 运行时——这篇带你用 Go 知识「无痛迁移」。
为什么写这篇
如果你是一个 Golang 工程师,大概率没想过在 .NET 技术栈里跑 AI Agent——主流 Agent 框架几乎都是 Python / Node 优先,少数有 Go 版。
OpenClaw.NET 就是来打破这个局面的:一个用 .NET 写的自托管 AI Agent 运行时 + 网关,自带鉴权、策略、记忆、可观测性和多渠道接入。它已经开源。
最对 Go 工程师胃口的一点:它能用 NativeAOT 编译成一个无依赖的单文件原生二进制——就是你熟悉的 go build 产物的 .NET 版本,启动即原生码、无 JIT、无运行时安装。
用一句 Go 话说:
它像一个「单二进制的 Go 网关服务」——对外是 HTTP / WebSocket / 各 IM 的 webhook,对内跑着一个能调工具、读写记忆、跨渠道对话的 AI Agent。
本文全程用 Go 概念做类比。读完你能:看懂系统组成和消息流转、在本地把它跑起来、并写出你的第一个工具 / 技能 / 插件 / 渠道。
一、30 秒认识 OpenClaw.NET
|
|
|
|---|---|
|
|
/chat)/ 各 IM webhook / OpenAI 兼容端点(/v1/*)/ MCP(/mcp)
|
|
|
|
|
|
|
|
|
|
|
|
|
项目已开源。仓库里解决方案叫
OpenClaw.Net.slnx,命名空间是OpenClaw.*——和名字对得上,找代码不迷路。
二、Go → .NET 心智模型速查
这是全文最该先读的部分。看懂这张表,后面 90% 的代码你都能读:
|
|
|
|---|---|
go build
|
NativeAOT
|
go func()
|
Task
async/await(协作式异步,语法级)
|
chan T
|
System.Threading.Channels
|
context.Context
|
CancellationToken
|
go.mod
go get
|
.csproj
dotnet CLI
|
|
|
.slnx(solution)含多个 .csproj
|
net/http
|
|
|
|
Microsoft.Extensions.DependencyInjection
|
encoding/json
|
System.Text.Json
|
interface{}
|
class Foo : ITool)
|
struct
|
record
class(注意:默认引用语义)
|
defer f.Close() |
using
await using(确定性释放)
|
error
|
throw / try-catch)
|
nil
|
可空引用类型
string? vs string(编译期强制)
|
go test
|
|
语法上最容易愣住的三个点
// 1) 异步:async/await ≈ 编译器帮你写好的 goroutine 编排
publicasyncTask<string>RunAsync(Session session,string msg,CancellationToken ct)
{
var result =await _llm.CallAsync(msg, ct);// await 让出执行权,不阻塞线程
return result.Text;
}
// CancellationToken ≈ context.Context:Go 里习惯放第一个参数,这里放最后一个。
// 作用和 ctx.Done() 一样——全链路取消信号,务必往下传。
// 2) record + required:≈ Go struct,但带编译期必填校验和不可变语义
publicsealedrecordOutboundMessage
{
public required string ChannelId {get;init;}// 不给就编译不过
public required string RecipientId {get;init;}
}
// 注意:C# 的 class/record 默认是引用语义,不像 Go struct 是值拷贝
// 3) 可空引用类型:string? 可空,string 不可空(编译器强制检查)
// ≈ Go 里"这个指针可能是 nil"被提升到了类型系统层面
string? maybe =GetOrNull();
string sure = maybe;// ⚠ 编译警告——本项目警告即错误!
string sure2 = maybe ??"default";// 用 ?? 兜底,类似 if x == nil { x = "default" }
依赖注入:≈ 内置版的 uber-fx
Go 里你要么手动在 main() 里 new 一圈传参,要么上 fx / wire。.NET 把 DI 容器内置了:
// 注册(≈ fx.Provide / wire.Bind)
services.AddSingleton<IMemoryStore, FileMemoryStore>();
// 解析(≈ 从容器里取,找不到直接 panic……哦不,抛异常)
var store = sp.GetRequiredService<IMemoryStore>();
本项目几乎全是单例(Singleton),注册按职责拆成一堆扩展方法,在 Program.cs 里顺序调用——和你在 main() 里组装依赖的拓扑结构一模一样,只是挪进了容器。
一个绕不开的硬约束:NativeAOT 与裁剪
先说好消息:NativeAOT 对 Go 工程师零学习成本——编译产物就是无依赖单文件原生二进制,冷启动毫秒级,跟 CGO_ENABLED=0 go build 的体验完全一致。
但代价你要清楚。本项目开了激进裁剪(TrimMode=link),后果是:
- 不能用运行时反射
。Go 里 encoding/json那种"扔个 struct 进去靠 tag 反射"的玩法,在这里会被裁剪器误伤——它会把"看起来没人引用"的类型删掉。 - JSON 序列化用源生成器
:为类型声明 JsonSerializerContext,编译期生成序列化代码:
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(OperatorAccountService.StoreState))]
internalpartialclassGatewayJsonContext:JsonSerializerContext;
Go 视角:这相当于强制你所有序列化都走 easyjson / ffjson 那种代码生成路线,而不是标准库的反射路线——更快,且裁剪安全。你新增 DTO 时,记得挂到某个 JsonSerializerContext 上。
裁剪还引出贯穿全文的两条运行时车道:
aot车道:裁剪安全、低内存、无动态加载。生产 Docker 镜像走这条。 jit车道:完整 .NET,支持反射和进程内动态加载插件。开发期默认走这条。
三、一条消息的一生
这是理解整个系统的主线。中枢是 OpenClaw.Gateway——它在启动时把 Agent 运行时、消息管道、渠道适配器、插件宿主组合起来,统一路由所有流量。
一条用户消息从进来到回复,共 11 步(Go 类比已标注):
- 渠道收消息
: IChannelAdapter把入站消息写进MessagePipeline——基于System.Threading.Channels,就是一个有界 buffered channel - Worker 取消息
:1~4 个 worker(上限 = CPU 核数)从 channel 读——就是你常写的 worker pool 模式 - 会话加锁
:拿该会话的 SemaphoreSlim(≈ 容量为 1 的信号量),同一会话不并发跑两轮 - 过中间件
:限流、token 预算,可短路拒绝——≈ gin 的 middleware 链 - 进 Agent 运行时
: MafAgentRuntime.RunAsync(...) - 准备上下文
:载入/新建会话、裁剪历史、注入记忆召回 - ReAct 循环
:调 LLM → 要工具就执行 → 结果回灌 → 再调 LLM……直到产出文本 - 工具执行
:一条完整链路——预设过滤 → 治理策略 → Hook → 人工审批 → 执行 → 审计 - 韧性
:LLM 调用自带指数退避重试、超时、断路器、降级模型级联 - 落库
:会话写入 IMemoryStore(dev 默认 sqlite) - 回复出站
:按 ChannelId找到渠道适配器投递
整个系统的「骨架接口」都在 src/OpenClaw.Core/Abstractions/:ITool、IChannelAdapter、IAgentRuntime、IMemoryStore、IToolHook……看懂它们 = 看懂系统的全部可扩展面。
一个和 Go 的习惯差异:Go 的 interface 是隐式满足(实现方法就自动算实现),C# 是显式声明(
class MyTool : ITool)。所以要扩展,就是「声明实现某个接口 + 注册进 DI」——思路和你定义type Handler interface然后注入实现完全一致。
一个「源码 > 文档」的现状澄清:README 说编排器默认是
native、MAF 可选,但当前开源版本实际只跑 MAF(Microsoft Agent Framework)——native 运行时已不在仓库中,选错 orchestrator 启动会直接抛异常。把「MAF + jit」当作唯一运行时理解即可。
四、把它跑起来
前置:.NET 10 SDK(必须,相当于装个新版 Go toolchain)、可选 Node.js 20+(仅跑 TS/JS 插件时需要)、一个 LLM API Key。
# 先校验配置(≈ 启动前自检,类似 viper 加载完配置先 Validate)
dotnet run --project src/OpenClaw.Gateway -c Release -- --doctor
# 启动
dotnet run --project src/OpenClaw.Gateway -c Release
默认监听 http://127.0.0.1:18789,浏览器打开 /chat 即可对话。
最快的本地启动(三个环境变量 + 一条命令):
exportMODEL_PROVIDER_KEY="sk-..."# 你的 LLM key
exportOPENCLAW_WORKSPACE="$PWD/workspace"# 工作区根目录
mkdir-p"$OPENCLAW_WORKSPACE"
dotnet run --project src/OpenClaw.Gateway -c Release
配置体系和 Go 服务常见的「配置文件 + 环境变量覆盖」一个套路:环境变量用双下划线映射层级,OpenClaw__Runtime__Mode ↔ 配置树 OpenClaw:Runtime:Mode——≈ viper 的 SetEnvKeyReplacer。敏感字段支持 env:VAR_NAME 引用写法,生产环境推荐。
本地避坑速查:
-
必须 .NET 10,多 SDK 并存时用 global.json钉版本(≈go.mod里的 toolchain 指令) -
出厂 appsettings.json里的默认AuthToken和示例 API key 仅供本地回环,对外部署务必改成env:引用,别把真实密钥提交进仓库 -
公网绑定会被安全硬化拦截(缺鉴权 token、危险工具、 raw:密钥都会拒绝启动)——这是有意设计
五、动手扩展:四种方式,从轻到重
① 写一个工具(Tool)—— 最常用
一个工具就是一个实现 ITool 的类。Go 视角:就是你定义一个 interface,然后写个 struct 实现它,只不过 C# 要显式声明:
publicinterfaceITool
{
string Name {get;}// LLM 用它来调用
string Description {get;}// 决定 LLM 何时调用它
string ParameterSchema {get;}// 参数的 JSON Schema
ValueTask<string>ExecuteAsync(string argumentsJson,CancellationToken ct);
}
最小可用示例(字符串反转工具):
publicsealedclassReverseTextTool:ITool// ← 显式声明实现,Go 里不需要这一下
{
publicstring Name =>"reverse_text";
publicstring Description =>"Reverse the characters of the given text.";
publicstring ParameterSchema =>"""{"type":"object","properties":{"text":{"type":"string","description":"Text to reverse"}},"required":["text"]}""";
publicValueTask<string>ExecuteAsync(string argumentsJson,CancellationToken ct){
// 用 JsonDocument 解析入参(AOT 安全,不走反射)
usingvar doc = JsonDocument.Parse(argumentsJson);
var text = doc.RootElement.GetProperty("text").GetString()??"";
returnnewValueTask<string>(newstring(text.Reverse().ToArray()));
}
}
然后把它加进内置工具列表(CreateBuiltInTools(...),就是个 new 一圈的组装函数——和你在 main() 里手动 wire 一样直白),重启网关,对它说「reverse the text hello」——工具调用会经过完整的审计/治理/审批链路。
② 写一个技能(Skill)—— 最轻,纯 Markdown
技能不是代码,而是一份「给 Agent 的操作手册」。Go 视角:工具 = 你实现的 Handler,技能 = 一份 Runbook 文档,教 Agent 遇到某类任务怎么组合调用已有工具。
机制是渐进式披露:系统提示里只放技能索引(省 token)→ Agent 判断相关时拉取完整正文 → 需要时再读附属文件。
创建只需一个文件夹 + 一个 SKILL.md,零编译:
---
name: pr-reviewer
description: 当用户要求审查一个 Pull Request 或 diff 时使用。
---
## 步骤
1. 用 `read_file` 或 `shell`(git diff)拿到改动
2. 按正确性、边界、安全、可读性审查
3. 输出分级意见:🔴 必须改 / 🟡 建议 / 🟢 可选
重启(或开热加载)即生效——比 Go 的热重载还省事。
③ 写一个插件(Plugin)—— 打包一组能力
两条路:原生 .NET 动态插件(进程内 DLL 加载,仅 jit 车道)和 JS/TS 桥接插件(Node.js 子进程 + JSON-RPC,两条车道都行)。
Go 工程师对这条不陌生:≈ Go 的 plugin 包(进程内 .so 加载,一堆限制)vs HashiCorp go-plugin(子进程 + RPC)——连取舍都一模一样。前者性能好但限制多,后者隔离性强、语言自由。原生契约仅 45 行:
publicsealedclassMyPlugin:INativeDynamicPlugin
{
publicvoidRegister(INativeDynamicPluginContext context){
context.RegisterTool(newReverseTextTool());
// 还能 RegisterChannel / RegisterHook / RegisterProvider ...
}
}
④ 接一个渠道(Channel)—— 接你自己的 IM
契约是 IChannelAdapter(收 + 发),入站走「webhook → handler 校验解析 → 管道入队」,出站按 ChannelId 路由投递。照抄 Twilio SMS 的实现(最简单的参照)即可,6 步:配置类 → 适配器 → webhook handler → DI 注册 → 挂适配器 → 映射端点。
webhook handler 的核心形态,Go 工程师一眼熟:
// ≈ func (h *Handler) ServeHTTP(w, r):验签 → 解析 → 白名单 → 入队
publicasyncValueTask<WebhookResult>HandleAsync(
string bodyText,string? signature,
Func<InboundMessage, CancellationToken, ValueTask> enqueue,CancellationToken ct){
if(_config.ValidateSignature &&!IsValidSignature(bodyText, _secret, signature))
return WebhookResult.Unauthorized();
// ... 解析、白名单校验 ...
awaitenqueue(newInboundMessage{ ChannelId ="myim", SenderId = senderId, Text = text }, ct);
return WebhookResult.Ok();
}
每个渠道都该有的安全面:签名校验(恒定时间比较,≈ Go 的
hmac.Equal)、发送者白名单、体积上限、去重窗口。
选型一图流
|
|
|
|
|---|---|---|
|
|
工具 |
|
|
|
技能 |
|
|
|
插件 |
|
|
|
渠道 |
|
六、开发约定:三个必须知道的红线
- 警告即错误
( TreatWarningsAsErrors=true)+ 可空性强制——≈ 把go vet+staticcheck+nilness全部调成 fail-the-build。第一次写会被编译器频繁拦,但能挡掉一大类 nil panic。 - JSON 必须走源生成器
,别依赖反射式序列化( encoding/json的习惯在这里要改),否则 AOT 下运行时炸。 - 数据安全铁律
:记忆/会话默认落 ./memory/,严禁用「清空整库 / DROP / 删目录」做测试隔离——只删自己创建的数据,或用独立的 throwaway 路径(≈ 每个测试用t.TempDir())。
测试栈是 xUnit v3 + NSubstitute(≈ go test + testify/mock):
dotnet test# 全部(≈ go test ./...)
dotnet test--filter"FullyQualifiedName~ProcessToolTests"# 单类(≈ go test -run)
写在最后
对 Go 工程师来说,这套系统其实亲切得意外:单文件原生二进制、channel 式的消息管道、context 式的取消令牌、worker pool、显式依赖组装——你日常写 Go 服务的那套肌肉记忆,在这里几乎全部成立。
真正要适应的只有三件事:interface 要显式声明、错误用异常而非返回值、以及 NativeAOT 下的「禁反射」约束。迈过去,就是一马平川。
想继续深挖,最可靠的三个源码入口:
src/OpenClaw.Gateway/Program.cs—— 启动主线(≈ 你的 main.go)src/OpenClaw.Agent/MafAgentRuntime.cs—— Agent 循环本体 src/OpenClaw.Core/Abstractions/—— 所有可扩展接口(≈ 项目里的 interface.go)
本文所有代码与结论均对照开源仓库当前源码(运行时 = MAF + jit)。如果你发现与代码不符——以代码为准,也欢迎提 PR。
觉得有用,欢迎去 GitHub 点个 Star ⭐,也欢迎点赞 / 在看 / 转发给你的 Gopher 朋友 👋

