写给 PHP 工程师的 OpenClaw.NET 上手指南:用你熟悉的 PHP 思维,跑起一个生产级 AI Agent
习惯了「请求来、进程起、响应完、一切销毁」?这次要跑的是一个长驻内存的 daemon——如果你玩过 Swoole / RoadRunner,你已经知道这种模型的威力。
为什么写这篇
如果你是 PHP 工程师,你的世界观是 shared-nothing:每个请求一个干净的进程,状态靠 Redis / MySQL 外置,代码改完刷新就生效。Agent 框架的主流世界是 Python / Node——你想给业务接个 Agent,要么跨语言调服务,要么硬上 Swoole 生态里那几款还不成熟的方案。
OpenClaw.NET 给了第三条路:一个用 .NET 写的自托管 AI Agent 运行时 + 网关,鉴权、策略、记忆、可观测性、多渠道接入全部内置,还能用 NativeAOT 编译成一个无依赖的单文件原生二进制——部署从「nginx + php-fpm + opcache + composer install」简化成「拷贝一个文件,跑」。它已经开源,仓库在 github.com/clawdotnet/openclaw.net。
用一句 PHP 话说:
它像一个「Laravel 应用 + 常驻队列 worker 的合体」——对外是 HTTP / WebSocket / 各 IM 的 webhook,对内跑着一个能调工具、读写记忆、跨渠道对话的 AI Agent——只不过它不是请求级的,是一个长驻内存的 daemon。
本文全程用 PHP 概念做类比。读完你能:看懂系统组成和消息流转、在本地把它跑起来、并写出你的第一个工具 / 技能 / 插件 / 渠道。
一、30 秒认识 OpenClaw.NET
| 能力 | 说明 |
|---|---|
| 网关 | HTTP / WebSocket / 浏览器 UI(/chat)/ 各 IM webhook / OpenAI 兼容端点(/v1/*)/ MCP(/mcp) |
| Agent 运行时 | 推理循环、工具执行、记忆、会话、技能、策略、审批、断路器 |
| 渠道 | WebSocket、TG、Slack、Discord、Teams、WhatsApp,以及飞书 / 钉钉 / 企业微信 |
| 扩展点 | 工具(Tool)、技能(Skill)、插件(Plugin)、渠道(Channel)、LLM Provider |
| 客户端 | 浏览器 UI、CLI、Avalonia 桌面 App、Blazor WASM 运维面板、TUI |
项目已开源:https://github.com/clawdotnet/openclaw.net。仓库里解决方案叫
OpenClaw.Net.slnx,命名空间是OpenClaw.*——和名字对得上,找代码不迷路。
二、PHP → C# 心智模型速查
这是全文最该先读的部分。好消息:PHP 8 的类型系统和 C# 长得像亲兄弟。看懂这张表,后面 90% 的代码你都能读:
| 你在 PHP 里熟悉的 | .NET 里的对应物 |
|---|---|
Composer + composer.json + Packagist |
dotnet CLI + .csproj + NuGet |
PSR-4 自动加载 + namespace |
C# namespace(同名概念,无需 autoload 配置) |
| Laravel / Symfony | ASP.NET Core(Minimal API 风格) |
| Laravel 服务容器 + Service Provider | Microsoft.Extensions.DependencyInjection + 扩展方法——几乎一模一样 |
.env 文件 + env() 函数 |
appsettings.json + 环境变量分层覆盖 |
php artisan |
dotnet CLI 子命令 |
?string vs string(PHP 7.1+) |
string? vs string——写法都一样,但这里是编译期强制 |
declare(strict_types=1) |
全项目默认严格,且警告即错误 |
match 表达式(PHP 8) |
switch 表达式 + 模式匹配(更强,能解构) |
| 构造器属性提升(PHP 8) | record + required——更彻底 |
PHP 8 Attribute #[Attr] |
C# Attribute [Attr]——同款语法 |
| PHPUnit + Mockery | xUnit v3 + NSubstitute |
| ReactPHP / Amphp(异步) | Task + async/await——语言内置,不是库 |
| OPcache / JIT(PHP 8) | CLR JIT;另有 NativeAOT 直接出原生二进制 |
语法上最容易愣住的三个点
// 1) 异步:语言内置,不是 ReactPHP 那种库级方案
public async Task<string> RunAsync(Session session, string msg, CancellationToken ct)
{
var result = await _llm.CallAsync(msg, ct); // 让出执行权,不阻塞线程
return result.Text;
}
// CancellationToken ≈ Amphp 的 Cancellation:协作式取消信号,显式传参,务必往下传。
// 2) record + required:≈ readonly class + 构造器属性提升的终极形态
public sealed record OutboundMessage
{
public required string ChannelId { get; init; } // 不给就编译不过
public required string RecipientId { get; init; }
}
// 3) 可空类型:写法你熟,强度不同
string? maybe = GetOrNull();
string sure = maybe; // ⚠ 编译警告——本项目警告即错误!
string sure2 = maybe ?? "default"; // ?? 和 PHP 的空合并运算符一模一样
最大的思维转换:请求级进程 → 长驻内存 daemon
这是 PHP 工程师最需要重建的直觉。PHP-FPM 的世界里:请求结束 = 一切销毁,内存泄漏无所谓,静态变量撑不过一个请求,状态天然外置。
OpenClaw.NET 是一个长驻 daemon:进程起一次,跑几个月。意味着:
- 状态活在内存里:消息队列、worker、会话锁都是进程内对象——这正是它快的原因,也意味着内存泄漏从「无所谓」变成「慢性病」
- 单例是真的单例:DI 容器里注册的对象陪进程走完一生(≈ Laravel Octane 的语义,不是传统 FPM 的)
- 改代码要重启:没有「刷新即生效」,编译型语言的纪律
如果你用过 Swoole / RoadRunner / Laravel Octane,这套心智你已经有了——OpenClaw 天生就是这个模型,且不用担心 PHP 长驻时的那些扩展内存坑。
依赖注入:Laravel 工程师会直接上手
// 注册(≈ ServiceProvider 里的 $this->app->singleton())
services.AddSingleton<IMemoryStore, FileMemoryStore>();
// 解析(≈ app(IMemoryStore::class))
var store = sp.GetRequiredService<IMemoryStore>();
注册按职责拆成一堆扩展方法(≈ 一堆 ServiceProvider),在 Program.cs(≈ bootstrap/app.php)里顺序调用。容器、单例、构造器注入——Laravel 那套全部平移。
一个硬约束:NativeAOT 与裁剪
本项目要能编译成 NativeAOT 单文件原生二进制,开了激进裁剪(TrimMode=link),后果是:不能用运行时反射。PHP 里 new $className()、字符串拼类名动态实例化、call_user_func 那一套动态玩法——在 aot 车道都不行,裁剪器会把"看起来没人引用"的代码删掉。
最直接影响是 JSON 序列化:不靠反射,用源生成器——为类型声明 JsonSerializerContext,编译期生成序列化代码:
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(OperatorAccountService.StoreState))]
internal partial class GatewayJsonContext : JsonSerializerContext;
PHP 视角:相当于强制你放弃「json_decode 后靠运行时猜结构」,改为「编译期把每个 DTO 的序列化代码生成好」。你新增 DTO 时,记得挂到某个 JsonSerializerContext 上。
裁剪还引出贯穿全文的两条运行时车道:
aot车道:裁剪安全、低内存、无动态加载——原生二进制,生产 Docker 镜像走这条。jit车道:完整 .NET,支持反射和进程内动态加载插件——开发期默认走这条,动态性接近你习惯的 PHP 手感。
三、一条消息的一生
这是理解整个系统的主线。中枢是 OpenClaw.Gateway——它在启动时把 Agent 运行时、消息管道、渠道适配器、插件宿主组合起来,统一路由所有流量。
一条用户消息从进来到回复,共 11 步(PHP 类比已标注):
- 渠道收消息:
IChannelAdapter把入站消息写进MessagePipeline——≈ 一个进程内的有界队列 - Worker 取消息:1~4 个 worker 从队列读——≈
queue:work常驻消费进程,但它是进程内线程,不是独立进程 - 会话加锁:拿该会话的信号量,同一会话不并发跑两轮(≈ 你用 Redis 锁防重入,这里是内存原语)
- 过中间件:限流、token 预算,可短路拒绝——≈ Laravel 的中间件管道
- 进 Agent 运行时:
MafAgentRuntime.RunAsync(...) - 准备上下文:载入/新建会话、裁剪历史、注入记忆召回
- ReAct 循环:调 LLM → 要工具就执行 → 结果回灌 → 再调 LLM……直到产出文本
- 工具执行:一条完整链路——预设过滤 → 治理策略 → Hook → 人工审批 → 执行 → 审计
- 韧性:LLM 调用自带指数退避重试、超时、断路器、降级模型级联(≈ 内置了 Guzzle retry middleware 全家桶)
- 落库:会话写入
IMemoryStore(dev 默认 sqlite) - 回复出站:按
ChannelId找到渠道适配器投递
整个系统的「骨架接口」都在 src/OpenClaw.Core/Abstractions/:ITool、IChannelAdapter、IAgentRuntime、IMemoryStore、IToolHook……看懂它们 = 看懂系统的全部可扩展面。要扩展就实现接口 + 注册进容器——和你在 Laravel 里 bind 一个 interface 到实现一模一样。
四、把它跑起来
前置:.NET 10 SDK(必须,≈ 装一次 PHP 引擎,之后所有工程共用)、可选 Node.js 20+(仅跑 TS/JS 插件时需要)、一个 LLM API Key。
# 先校验配置(≈ artisan 自检命令)
dotnet run --project src/OpenClaw.Gateway -c Release -- --doctor
# 启动(≈ php artisan serve,但这是个真正的生产级服务器)
dotnet run --project src/OpenClaw.Gateway -c Release
默认监听 http://127.0.0.1:18789,浏览器打开 /chat 即可对话。注意:不需要 nginx、不需要 fpm——内建 Kestrel 服务器直接对外。
最快的本地启动(三个环境变量 + 一条命令):
export MODEL_PROVIDER_KEY="sk-..." # 你的 LLM key
export OPENCLAW_WORKSPACE="$PWD/workspace" # 工作区根目录
mkdir -p "$OPENCLAW_WORKSPACE"
dotnet run --project src/OpenClaw.Gateway -c Release
配置体系是「配置文件 + 环境变量覆盖」的分层套路,≈ Laravel 的 config/*.php + .env:环境变量用双下划线映射层级,OpenClaw__Runtime__Mode ↔ 配置树 OpenClaw:Runtime:Mode(≈ env('OPENCLAW_RUNTIME_MODE') 的自动化版本)。敏感字段支持 env:VAR_NAME 引用写法,生产环境推荐。
本地避坑速查:
- 必须 .NET 10,多 SDK 并存时用
global.json钉版本(≈composer.json里钉php: "10.x") - 出厂
appsettings.json里的默认AuthToken和示例 API key 仅供本地回环,对外部署务必改成env:引用,别把真实密钥提交进仓库 - 公网绑定会被安全硬化拦截(缺鉴权 token、危险工具、
raw:密钥都会拒绝启动)——这是有意设计
五、动手扩展:四种方式,从轻到重
① 写一个工具(Tool)—— 最常用
一个工具就是一个实现 ITool 的类。PHP 视角:就是你 implements 一个接口——这个手感完全一样:
public interface ITool
{
string Name { get; } // LLM 用它来调用
string Description { get; } // 决定 LLM 何时调用它
string ParameterSchema { get; } // 参数的 JSON Schema
ValueTask<string> ExecuteAsync(string argumentsJson, CancellationToken ct);
}
最小可用示例(字符串反转工具):
public sealed class ReverseTextTool : ITool // ≈ class ReverseTextTool implements ITool
{
public string Name => "reverse_text";
public string Description =>
"Reverse the characters of the given text.";
public string ParameterSchema => """
{
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to reverse" }
},
"required": ["text"]
}
""";
public ValueTask<string> ExecuteAsync(string argumentsJson, CancellationToken ct)
{
// 用 JsonDocument 解析入参(AOT 安全)≈ json_decode 后手动取字段
using var doc = JsonDocument.Parse(argumentsJson);
var text = doc.RootElement.GetProperty("text").GetString() ?? "";
return new ValueTask<string>(new string(text.Reverse().ToArray()));
}
}
然后把它加进内置工具列表(CreateBuiltInTools(...),≈ 在 ServiceProvider 里集中注册),重启网关,对它说「reverse the text hello」——工具调用会经过完整的审计/治理/审批链路。
② 写一个技能(Skill)—— 最轻,纯 Markdown
技能不是代码,而是一份「给 Agent 的操作手册」:教 Agent 遇到某类任务怎么组合调用已有工具。
机制是渐进式披露:系统提示里只放技能索引(省 token)→ Agent 判断相关时拉取完整正文 → 需要时再读附属文件。
创建只需一个文件夹 + 一个 SKILL.md,零编译、零部署:
---
name: pr-reviewer
description: 当用户要求审查一个 Pull Request 或 diff 时使用。
---
## 步骤
1. 用 `read_file` 或 `shell`(git diff)拿到改动
2. 按正确性、边界、安全、可读性审查
3. 输出分级意见:🔴 必须改 / 🟡 建议 / 🟢 可选
重启(或开热加载)即生效——这个倒是有 PHP 「刷新即生效」的手感。
③ 写一个插件(Plugin)—— 打包一组能力
两条路:原生 .NET 动态插件(进程内 DLL 加载,仅 jit 车道)和 JS/TS 桥接插件(Node.js 子进程 + JSON-RPC,两条车道都行)。
PHP 视角:前者 ≈ 加载一个编译好的扩展 .so 进进程(快但同居一室),后者 ≈ 起一个独立服务用 RPC 通信(隔离、语言自由)。生产环境(aot 车道)要跑自定义逻辑,走 TS 桥接插件。原生契约仅 45 行:
public sealed class MyPlugin : INativeDynamicPlugin
{
public void Register(INativeDynamicPluginContext context)
{
context.RegisterTool(new ReverseTextTool());
// 还能 RegisterChannel / RegisterHook / RegisterProvider ...
}
}
④ 接一个渠道(Channel)—— 接你自己的 IM
契约是 IChannelAdapter(收 + 发),入站走「webhook → handler 校验解析 → 管道入队」,出站按 ChannelId 路由投递。照抄 Twilio SMS 的实现(最简单的参照)即可,6 步:配置类 → 适配器 → webhook handler → DI 注册 → 挂适配器 → 映射端点。
webhook handler 的核心形态,写 Laravel 的你一眼熟:
// ≈ Route::post('/webhook', function (Request $request) {...}):验签 → 解析 → 白名单 → 入队
public async ValueTask<WebhookResult> HandleAsync(
string bodyText, string? signature,
Func<InboundMessage, CancellationToken, ValueTask> enqueue, CancellationToken ct)
{
if (_config.ValidateSignature && !IsValidSignature(bodyText, _secret, signature))
return WebhookResult.Unauthorized();
// ... 解析、白名单校验 ...
await enqueue(new InboundMessage { ChannelId = "myim", SenderId = senderId, Text = text }, ct);
return WebhookResult.Ok();
}
每个渠道都该有的安全面:签名校验(恒定时间比较,≈ PHP 的
hash_equals)、发送者白名单、体积上限、去重窗口。
选型一图流
| 你的需求 | 用 | 要编译吗 |
|---|---|---|
| 加一个 Agent 能调用的动作 | 工具 | 要 |
| 教 Agent 某类任务的处理流程 | 技能 | 不要(纯 md) |
| 打包一组能力 / 复用 TS 生态 | 插件 | 原生要 / 桥不要 |
| 接一个新的消息入口 | 渠道 | 要 |
六、开发约定:三个必须知道的红线
- 警告即错误(
TreatWarningsAsErrors=true)+ 可空性强制——≈declare(strict_types=1)+ PHPStan level max 且 CI 上一个警告都不许过。第一次写会被编译器频繁拦,但它挡掉的就是你在生产日志里最烦的那类Trying to access property on null。 - JSON 必须走源生成器,别指望反射式序列化,否则 AOT 下运行时炸。
- 数据安全铁律:记忆/会话默认落
./memory/,严禁用「清空整库 / DROP / 删目录」做测试隔离——只删自己创建的数据,或用独立的 throwaway 路径。
测试栈是 xUnit v3 + NSubstitute(≈ PHPUnit + Mockery):
dotnet test # 全部(≈ ./vendor/bin/phpunit)
dotnet test --filter "FullyQualifiedName~ProcessToolTests" # 单类(≈ phpunit --filter)
写在最后
对 PHP 工程师来说,这套系统最难的不是语法——?string、??、命名空间、接口、match,你看着全都眼熟。真正的跨越有两个:
运行模型:从「请求级、shared-nothing、改完即生效」到「长驻 daemon、内存状态、编译部署」。用过 Swoole / Octane 你已经跨过来一半,这套系统天生就是长驻模型,且编译型语言没有 PHP 长驻的那些扩展内存坑。
类型纪律:strict_types 在这里不是声明,是空气——警告即错误,可空性编译期强制。作为回报,你得到的是「编译过了基本就能跑」的踏实,和一个拷贝即部署的单文件二进制——没有 composer install,没有 opcache 预热,没有 fpm 进程管理。
想继续深挖,最可靠的三个源码入口:
src/OpenClaw.Gateway/Program.cs—— 启动主线(≈public/index.php+bootstrap/app.php)src/OpenClaw.Agent/MafAgentRuntime.cs—— Agent 循环本体src/OpenClaw.Core/Abstractions/—— 所有可扩展接口
本文所有代码与结论均对照开源仓库 clawdotnet/openclaw.net 当前源码(运行时 = MAF + jit)。如果你发现与代码不符——以代码为准,也欢迎提 PR。
觉得有用,欢迎去 GitHub 点个 Star ⭐,也欢迎点赞 / 在看 / 转发给你的 PHP 朋友 👋
欢迎大家扫描下面二维码成为我的客户,扶你上云


浙公网安备 33010602011771号