同事在群里发了张诡异的 grafana 截图:「我们 consumer 服务有 3950 个线程,正常吗?」

我盯着那条平稳的线问他:「你们项目里有多少个 RocketMQ Consumer?」

他打开代码搜了一下:「60 个。每个事件 Bean 一个。」

我说:「那线程数对,但你们这设计……值得聊聊。」

这篇讲一种 Spring + RocketMQ 实现的"自动事件总线"设计:业务 Bean 加个 @RemoteEvent 注解,启动时自动创建独立 Consumer + Topic + Group。设计很优雅,代价也很大。值不值?看你怎么用。

📌 这是《从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战》的姊妹篇——上次讲怎么从一个 MQ 积压告警一路反推出这个设计,这次讲设计本身的取舍。


一、问题源起:内部事件 vs 跨服务事件

先看这两段代码的差别:

// 场景 1:进程内事件 —— Spring 原生 ApplicationEvent
@Component
public class OrderListener {
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // 同进程内,直接调用,事务/线程都共享
    }
}

applicationContext.publishEvent(new OrderCreatedEvent(...));
// 场景 2:跨进程事件 —— 需要走 MQ
rocketMQTemplate.syncSend("topic-order-created",
    new OrderCreatedEvent(...));

// 另一个服务里
@RocketMQMessageListener(topic = "topic-order-created", consumerGroup = "...")
public class OrderListener implements RocketMQListener<OrderCreatedEvent> {
    public void onMessage(OrderCreatedEvent event) { ... }
}

两段代码做的事情几乎一样——接收一个事件,做一些处理。但场景 2 多了一堆样板:手动指定 topic、配置 listener、注册 group……每加一个新事件就要重复一遍。

有没有可能让跨进程事件用起来跟进程内事件一样轻?

@RemoteEvent 自动事件总线就是这个问题的一种解。


二、目标:让"加一个事件"变成"加一个 Bean"

理想的开发体验:

// 业务侧:事件类自带处理逻辑(payload + listener 合一)
@RemoteEvent              // ← 标记为远程事件,框架据此分流到 MQ
@Component                // ← 同时是 Spring Bean,被消费侧扫描到
public class UserSignupEvent implements EventListener<UserSignupEvent> {
    private Long userId;
    // 省略构造 / getter

    @Override
    public void onEvent(UserSignupEvent event) {
        // 处理用户注册事件
    }
}

// 发送方
applicationContext.publishEvent(new UserSignupEvent(userId));
// ↑ 框架自动转发到 RocketMQ,路由到所有订阅了 UserSignupEvent 的服务

注意上面事件类本身就是 Bean(同时贴了 @RemoteEvent@Component)。这种"payload = listener"的合一写法是这套设计的关键约定——后面 §3.3 会展开为什么必须这么写。

没有 topic 配置,没有 listener 注册,没有 group 命名。框架自己搞定。

先澄清:@RemoteEvent 是项目自定义的,不是 Spring/RocketMQ 自带

容易让人误解的是 @RemoteEvent 这个名字——它不是 Spring、Spring Cloud Stream、RocketMQ Spring Starter 里的注解,是这个事件总线项目自己定义的标记注解

@Target(ElementType.TYPE)                  // 只能贴在类上
@Retention(RetentionPolicy.RUNTIME)        // 运行时可见,让 getAnnotation() 拿得到
@Documented
public @interface RemoteEvent {
    // 空,无任何成员。纯标记。
    // 真正的"自动建 Consumer"逻辑全在下面 §三 的两个组件里:
    //   - 发送侧 MqSenderApplicationEventMulticaster(拦截 Spring publishEvent)
    //   - 消费侧 RemoteEventListenerAutoCreator(扫描这个注解自动建 Container)
}

也就是说,注解本身一行业务代码都没有,它的全部含义在于"打个标记让框架代码能识别"。看到 @RemoteEvent 在文中出现,理解成"贴了这个标记的类需要参与 MQ 路由"即可。

后续 §六.1 会提到给注解扩展 threadCount 等属性来精细控制——那是优化方向,基础版本就是上面这 5 行空注解。

@RemoteEvent 触发了框架在两个时机做的事

一张图说清楚:

   @RemoteEvent 注解 ──触发──▶ 框架在两个时机各做一件事
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   ┌──────────────── 启动时(一次性) ─────────────────┐
   │                                                │
   │   Spring 容器启动                                │
   │        │                                        │
   │        ▼  getBeansWithAnnotation(               │
   │        │      RemoteEvent.class)                │
   │   扫出所有标记 Bean:                              │
   │      · UserSignupEvent                          │
   │      · OrderPaidEvent                           │
   │      · ConfigChangedEvent                       │
   │      · ...                                      │
   │        │                                        │
   │        ▼                                        │
   │   每个 Bean 自动建一个                            │
   │   RocketMQ Consumer Container                   │
   │   (各自独立的 topic / group / tag)                │
   │                                                 │
   └─────────────────────────────────────────────────┘

   ┌──────────────── 运行时(每次发事件) ──────────────┐
   │                                                 │
   │   publishEvent(new XxxEvent(...))               │
   │        │                                        │
   │        ▼  自定义 ApplicationEventMulticaster     │
   │   检查 payload 类的 getAnnotation(RemoteEvent)   │
   │        │                                        │
   │     ┌──┴──┐                                     │
   │   有│     │无                                    │
   │     ▼     ▼                                     │
   │   发 MQ   走 Spring 默认(同进程内分发)            │
   │                                                 │
   └─────────────────────────────────────────────────┘

注解就是个开关:贴了它,框架启动时多一个 Consumer Container,运行时该事件走 MQ 而不是本地分发;不贴,Spring 原生 @EventListener 行为不变。

下面拆开看它的发送侧、消费侧、命名契约怎么实现这种"零样板"体验。


三、实现机制:发送侧 + 消费侧两端协同

整个事件总线由两个组件支撑:

发送侧:用自定义 ApplicationEventMulticaster 接管 Spring 事件分发
                ↓
            RocketMQ
                ↓
消费侧:扫描 @RemoteEvent Bean 自动注册独立 Consumer Container

业务侧只看到 applicationContext.publishEvent(...) 这一行代码,看不到 MQ。两端通过约定的命名规则对接:以 payload 类的 beanName 作为 topic 和 group 的前缀。

3.1 发送侧:替换 ApplicationEventMulticaster

Spring 处理 publishEvent 时,会把事件交给一个名叫 applicationEventMulticaster 的 Bean 分发。默认实现是 SimpleApplicationEventMulticaster。事件总线接管这一步靠一个特殊命名的 @Primary Bean

@Configuration("applicationEventMulticaster")  // 必须叫这个名字
@Primary
public class MqSenderApplicationEventMulticaster
        extends SimpleApplicationEventMulticaster {

    @Override
    public void multicastEvent(ApplicationEvent event, ResolvableType type) {
        // 1. 不是 PayloadApplicationEvent 或全局开关关 → Spring 默认分发
        if (!(event instanceof PayloadApplicationEvent) || !remoteEventEnabled) {
            super.multicastEvent(event, type);
            return;
        }

        PayloadApplicationEvent<?> payloadEvent = (PayloadApplicationEvent<?>) event;

        // 2. payload 类没有 @RemoteEvent 注解 → 本地事件,走 Spring 默认
        if (payloadEvent.getPayload().getClass().getAnnotation(RemoteEvent.class) == null) {
            super.multicastEvent(event, type);
            return;
        }

        // 3. 是远程事件 → 包装成 MQ 消息
        Message message = createMessage(payloadEvent.getPayload());

        // 4. 在事务里 → 注册同步器,等事务结束再发;否则立即发
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            registerTransactionSynchronization(message);
        } else {
            mqSender.send(message);
        }
    }

    // topic 约定:用 payload 类对应 Bean 的名字
    private String getBusinessName(Object source) {
        return applicationContext.getBeanNamesForType(source.getClass())[0];
    }
}

几个关键设计决策

决策 为什么这么做
必须叫 applicationEventMulticaster Spring 在 AbstractApplicationContext.initApplicationEventMulticaster 里写死按这个名字找 Bean
@Primary 兜底优先级,避免有其他实现冲突
只拦 PayloadApplicationEvent 避开 Spring 内部事件(ContextRefreshedEvent 等),不会被错误推到 MQ
@RemoteEvent 注解贴在 payload 类上 业务发事件代码完全一样,路由由注解决定,业务侧零感知
在事务里要注册 TransactionSynchronization 业务事务还没 commit 就发消息,消费方反查 DB 查不到

⚠️ 这里第 4 步埋了个事务消息最常踩的坑——TransactionSynchronization 有 4 个回调钩子,用错一个字符就会让"事务回滚也发了消息"。这部分单独成篇了,详见 《事务回滚了消息却发出去:Spring 事务消息的 3 种姿势对比》

一个隐含取舍@RemoteEvent 走了 MQ 之后不再走本地分发。如果同进程也要订阅这个事件,必须在消费侧像其他订阅者一样注册一个 @RemoteEvent Bean——本地+远程"同时收"是不支持的

3.2 消费侧:扫描 + 自动注册 Container

@Configuration
public class RemoteEventListenerAutoCreator
        implements ApplicationContextAware, SmartInitializingSingleton {

    @Override
    public void afterSingletonsInstantiated() {
        // 1. 找出所有 @RemoteEvent 注解 Bean
        Map<String, Object> beans = applicationContext
            .getBeansWithAnnotation(RemoteEvent.class);

        // 2. 给每个 Bean 注册一个独立的 RocketMQ Consumer Container
        beans.forEach((beanName, instance) -> {
            // 命名约定:topic、group、tag 都按 beanName 派生
            // ←→ 跟发送侧的 getBusinessName 一致
            String topic = beanName + "_" + profile;
            String group = beanName + "_group_" + profile;
            String tag   = beanName + "_tag";

            registerBean(
                "container_" + beanName,
                DefaultRocketMQListenerContainer.class,
                () -> {
                    DefaultRocketMQListenerContainer c = new DefaultRocketMQListenerContainer();
                    c.setConsumerGroup(group);
                    c.setTopic(topic);
                    c.setSelectorExpression(tag);
                    c.setRocketMQListener(buildGenericListener(instance));
                    return c;
                }
            );
        });
    }

    @Override
    public void afterContainersStarted() {
        // 3. 启动时统一调参
        containers.forEach(c -> {
            DefaultMQPushConsumer consumer = c.getConsumer();
            consumer.setPullBatchSize(90);
            consumer.setConsumeTimeout(30000);
            consumer.setConsumeMessageBatchMaxSize(20);
            consumer.setPullInterval(50);
            c.start();
        });
    }
}

消费侧整个机制 3 个动作:

扫描 @RemoteEvent 注解 Bean
    ↓
按 beanName 派生 topic/group/tag
    ↓
给每个 Bean 注册一个独立的 Consumer Container

3.3 两端怎么对接:约定大于配置

发送侧检查 payload 类payloadEvent.getPayload().getClass())有没有 @RemoteEvent、并以 payload 类的 beanName 当 topic 前缀;消费侧扫描 所有 @RemoteEvent 注解的 Bean、并以 该 Bean 的 beanName 当订阅 topic 前缀。

这两个 beanName 必须指向同一个东西才能把消息送到正确的 topic。最干净的做法是 §二 给出的写法:

让事件类本身就是 Spring Bean,自处理自己UserSignupEvent 同时贴 @RemoteEvent + @Component,实现 EventListener<UserSignupEvent>)。

这样 payload 类就是 listener Bean,发送侧的 payload.getClass() 和消费侧扫描出来的 Bean 是同一个类,beanName 天然一致(默认 userSignupEvent)。

这是隐含的命名契约——重构类名或动 @Component("xxx") 别名前要慎重,改了发送侧识别不到对应 topic、消费侧绑不到 listener,症状是"消息发出去了但没人消费",排查难度极大。


四、设计的好处:拓扑由代码自描述

4.1 零样板代码

业务方加新事件的成本就是写一个 Bean。不用动配置、不用动 topic 注册脚本、不用通知运维加 consumer group。事件的拓扑结构完全写在代码里,谁订阅、谁发布一目了然。

4.2 事件按业务隔离

每个事件一个独立 topic + group:

隔离维度 收益
topic 隔离 一个事件的消息堆积不会拖累其他事件的消费
group 隔离 每个事件独立维护 offset,重置某个事件不影响其他
consumer 池隔离 一个事件的慢消费(比如调外部 API 慢)不会占满公共池
监控隔离 broker 上每个事件的消费速率、堆积量独立可见

4.3 多服务订阅同一事件天然支持

服务 A 发 UserSignupEvent,服务 B 和服务 C 各自在工程里也定义同样的 @RemoteEvent 标记的 UserSignupEvent Bean 监听这个事件——两个服务自动各起一个 groupUserSignupEvent_group_serviceB / UserSignupEvent_group_serviceC),每个服务都能独立消费一份。

                    多服务订阅同一事件的拓扑
                    ━━━━━━━━━━━━━━━━━━━━━━━━

   Service A (生产方)
   ┌──────────────────────────┐
   │ @RemoteEvent             │
   │ @Component               │
   │ class UserSignupEvent {  │
   │   void onEvent(...)      │
   │ }                        │──┐ publishEvent
   └──────────────────────────┘  │
                                 ▼
                  ┌──────────────────────────────┐
                  │     RocketMQ broker          │
                  │                              │
                  │ topic = userSignupEvent_prod │
                  │                              │
                  │ groups (各服务独立 offset):   │
                  │   · _group_serviceA          │
                  │   · _group_serviceB          │
                  │   · _group_serviceC          │
                  └─────┬────────┬────────┬──────┘
                        │        │        │
            ┌───────────┘        │        └────────────┐
            ▼                    ▼                     ▼
   Service A                Service B               Service C
   ┌──────────────┐        ┌──────────────┐        ┌──────────────┐
   │@RemoteEvent  │        │@RemoteEvent  │        │@RemoteEvent  │
   │@Component    │        │@Component    │        │@Component    │
   │UserSignup    │        │UserSignup    │        │UserSignup    │
   │Event         │        │Event         │        │Event         │
   │              │        │              │        │              │
   │onEvent(...): │        │onEvent(...): │        │onEvent(...): │
   │业务 A 自处理   │        │业务 B 处理    │        │业务 C 处理    │
   └──────────────┘        └──────────────┘        └──────────────┘

注意:三个服务里的 UserSignupEvent 是三个独立的类(各自工程里复制一份, 包名/字段可以一致),它们靠"类名 → beanName → topic 前缀"这个约定串起来。没有共享 jar 包,没有契约文件。这种"约定"对开发体验友好,但对跨团队协作不友好(改了一处类名,其他服务订阅链路断了无人知晓)——这是 §三.3 命名契约那条提醒的根源。

发布订阅的扇出语义自动实现,不需要任何人为干预。


五、设计的代价:线程数线性增长

但这优雅的代价非常具体——线程数随 @RemoteEvent Bean 数量线性增长

5.1 一个 @RemoteEvent Bean 的线程账单

每个 Bean 启动后自动伴生:

线程组 数量 来源
ConsumeMessageThread_<1..20> 20 DefaultMQPushConsumer 默认 consumeThreadMax=20
CleanExpireMsgScheduledThread_1 1 过期消息清理
MQClientInstance 内部线程 ~5 NettyClient / Pull / Rebalance / HouseKeeping 等核心定时任务,理想情况下多个 Bean 共享同一个 Instance

单个 Bean 至少新增 21 个独占线程(不含 MQClientInstance,因为它本应是进程级共享的)。

5.2 真实项目的账

某线上 consumer 服务的实测数据:

60 个 @RemoteEvent Bean × 20 ConsumeMessageThread/Bean = 1200 个消费线程
70 个 RocketMQ Producer × 10 trace 线程              =  700 个 trace 线程
70 个 Producer × 4 AsyncSenderExecutor               =  280 个发送线程
140 个 MQClientInstance × ~4 线程/Instance           =  500+ 个心跳/拉取线程
                                                      ─────────
                                              合计    ≈ 3000+ 线程

⚠️ 等一下——按 §5.1,MQClientInstance 不是进程级共享吗?为什么这里冒出 140 个?
这正是这套设计最容易翻车的点之一:框架或业务代码里某些不经意的配置(自定义 instanceName / RPCHook / 多 NameServer 集群等)会打破默认共享,让 Instance 按 group / 按 Producer 分裂。详细排查方法见 §6.2。

加上业务自己的线程池(async-task 200、event-executor 300、IM/AI/图片处理等 ~300),总线程数轻松破 4000

线程数破 4000 直接的代价(Linux 默认 -Xss=1m):

4000 线程 × 1MB 栈 = 4GB 虚拟栈空间

虚拟内存上限并不等于 RSS 实际占用,但每个线程内核态栈 + JVM 内部结构 + 上下文切换开销叠加起来仍然非常可观。加上 3GB 堆 + 0.5GB 元空间 + JVM 自身 + Skywalking agent,一个 8GB 内存的机器物理内存几乎榨干。一旦突发流量或云厂商抖动一下,这种临界状态的进程直接 OOM-Killer 给端了——这就是姊妹篇那次事故的根本起因。


六、4 个调优旋钮

如果不想退回到"手动配置每个事件"的老路,但又想缓解线程膨胀,有几个具体的调优旋钮,按 ROI 排序:

旋钮 实施成本 线程节省(基于本文 60 Bean 项目)
§6.1 调小单 Consumer 线程数 改一行代码 ~480 个(1200 → 720)
§6.2 共享 MQClientInstance 排查 + 改配置 ~550 个(140 → 1~2 个 Instance)
§6.3 trace 选择性关闭 改注解 + 灰度 ~500 个(按关闭比例)
§6.4 合并低频事件到 1 个 topic 架构改动较大 视合并范围,10 个 Bean 合并约省 200 个

6.1 调小单 Consumer 线程数(最容易,ROI 最高)

DefaultMQPushConsumer 默认 consumeThreadMin=20 / consumeThreadMax=20事件总线场景下大部分事件 QPS 极低(很多业务事件每天就几条),20 个消费线程严重过剩。

// 在 RemoteEventListenerAutoCreator 启动 Container 前显式设置
consumer.setConsumeThreadMin(4);
consumer.setConsumeThreadMax(8);

60 × 12 = 720 → 比默认 60 × 20 = 1200 砍掉 480 个线程

进一步可以分级——把 @RemoteEvent 注解扩展支持优先级或预估 QPS:

@RemoteEvent(threadCount = 4)         // 低频事件,4 个线程足够
@Component
public class UserSignupEvent implements EventListener<UserSignupEvent> { ... }

@RemoteEvent(threadCount = 32)        // 高频事件,给足
@Component
public class OrderPaidEvent implements EventListener<OrderPaidEvent> { ... }

6.2 共享 MQClientInstance(治本性最强)

RocketMQ 内部 MQClientInstance 默认按 (clientId, RPCHook) 共享,clientId 默认是 IP@PID。如果不动配置,同一进程内所有 Producer/Consumer 应该自动共享同一个 MQClientInstance——但实测项目里有 140 个 Instance,说明被业务代码或框架配置打破了共享。

排查方向:

  • 是否给某些 Producer/Consumer 显式设置了不同的 instanceName
  • 是否使用了不同的 RPCHook(比如 ACL 认证)
  • 是否在多个 NameServer 集群上同时连

修复后 140 个 Instance → 1~2 个,能省下 ~138 套 NettyClient / HouseKeeping 线程组(每组 ~4 线程,共节省 ~550 个线程,与 §5.2 表里 500+ 的实测对得上)。

6.3 trace 选择性关闭(按业务关键度筛)

RocketMQ 客户端启用 trace(enableMsgTrace=true)会额外启动 10 个 MQTraceSendThread + 1 个 AsyncTraceDispatcher。70 个 Producer 全部开 trace 就是 700+ 个 trace 线程。

实操建议:

  • 核心交易 / 业务关键路径保持 trace 开启(trace 数据有价值)
  • 低 QPS 事件 / 内部调试事件关闭 trace
  • 配置层让 @RemoteEvent 注解支持 enableTrace 属性

6.4 合并低频事件 + tag 区分(架构改动稍大)

如果业务里有大量"每天就几条"的冷事件(比如管理员操作日志、配置变更通知等),可以考虑合并到同一个 topic 用 tag 区分

@RemoteEvent(topic = "admin_events", tag = "config_changed")
@Component
public class ConfigChangedEvent implements EventListener<ConfigChangedEvent> { ... }

@RemoteEvent(topic = "admin_events", tag = "user_banned")
@Component
public class UserBannedEvent implements EventListener<UserBannedEvent> { ... }

10 个冷事件合并成 1 个 topic → Consumer 数从 10 降到 1,省 19 个线程。架构改动稍大,但对 Bean 数巨多的项目收益可观。


七、适用场景与陷阱

7.1 这个设计适合什么场景

适合 不适合
事件类型多但每类 QPS 不高 事件类型少但每类 QPS 极高
强调事件间隔离(一个慢消费不影响其他) 强调资源效率(线程预算紧张)
跨服务事件订阅频繁变更 事件订阅长期固定
团队大、希望约定大于配置 单团队小服务,手动配置可接受

7.2 几个常见陷阱

陷阱 1:把进程内事件也当跨进程事件

如果一个事件只有同进程的 Bean 订阅,根本不需要走 MQ。这种情况下 @RemoteEvent 反而比 Spring 原生 @EventListener 慢一个数量级(多了一次 broker 往返)。架构层要明确区分"内部事件"和"远程事件"。

陷阱 2:盲目加注解

@RemoteEvent 的边际成本是 21+ 个线程。没认真盘算的项目,10 个迭代下来 Bean 从 5 个变 50 个,线程从 100 个变 1000+。Code Review 时把这条加进 checklist。

陷阱 3:默认参数不改

consumeThreadMax=20 是 RocketMQ 给"消息量大、单条处理快"的场景准备的。事件总线场景大部分是反过来的——消息量小、单条处理可能慢(比如调外部 API)。把默认值改成 4~8 几乎没有副作用,但能把线程数砍下去 60%。

陷阱 4:trace 一刀切开/关

trace 数据有价值,但 11 个线程的代价不便宜。按事件粒度配置开关,比一刀切实际得多。


八、值不值得用?我的判断

抛出问题:@RemoteEvent 这个设计,到底是赚还是亏?

我的答案是——取决于项目阶段和团队规模

8.1 早期项目 / 小团队 / 事件 < 10 个:直接用 RocketMQTemplate

事件不到 10 个,手动配 topic/listener 工作量可控,不需要这么重的抽象。3 个事件就上事件总线,是过度设计

8.2 中期项目 / 多团队 / 事件 20~50 个:值得引入

事件数量 20+ 且持续增长,跨服务订阅频繁,每加一个事件都要协调 N 个团队改 listener 配置——这种摩擦带来的代价会很快超过 21 个线程的代价。事件总线是一种用资源换协作效率的设计。

8.3 后期项目 / 大规模 / 事件 50+ 个:必须配套治理工具

事件数到 50+ 时,线程账单就不是小事了。必须配套

  • @RemoteEvent 注解级别的线程数 / trace 开关参数
  • 监控大盘看每个 Bean 对应的 ConsumeMessageThread 占用、消费速率、堆积量
  • 静态扫描工具,禁止在 PR 阶段加无 QPS 评估的新 @RemoteEvent Bean

没有治理工具配套的事件总线,等着哪天告警群里炸出"3950 线程"


九、总结

维度 评价
开发体验 ⭐⭐⭐⭐⭐ 加个注解就行,事件拓扑代码自描述
业务隔离 ⭐⭐⭐⭐⭐ topic/group/池都独立,故障隔离效果好
资源效率 ⭐⭐ 线程数随 Bean 数线性涨,默认参数不改容易翻车
可观测性 ⭐⭐⭐⭐ broker 端按 topic 看一切都很清楚
治理成本 ⭐⭐ 必须配套监控和静态扫描,否则会失控

设计上没有银弹只有取舍@RemoteEvent 用 21 个线程换一个事件 Bean 的"零样板",赚不赚要看你的事件 QPS 分布、团队结构、运维能力。

但有一点是确定的:

不调默认参数 + 不做治理工具,无脑铺事件总线,最后一定会用一次线上事故来"教育"你。

至于这个设计怎么从一次 4000 线程吃光内存的事故里被反推出来的,详见 《从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战》

给读者的小问题:如果让你自己设计一个事件总线,你会用 1 topic 1 事件,还是 1 topic N 事件按 tag 区分?两种方案的成本和收益分别是什么? 评论区聊。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐