@RemoteEvent 自动事件总线:1 个注解换 60 个 Consumer,赚还是亏?
同事在群里发了张诡异的 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 监听这个事件——两个服务自动各起一个 group(UserSignupEvent_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 评估的新
@RemoteEventBean
没有治理工具配套的事件总线,等着哪天告警群里炸出"3950 线程"。
九、总结
| 维度 | 评价 |
|---|---|
| 开发体验 | ⭐⭐⭐⭐⭐ 加个注解就行,事件拓扑代码自描述 |
| 业务隔离 | ⭐⭐⭐⭐⭐ topic/group/池都独立,故障隔离效果好 |
| 资源效率 | ⭐⭐ 线程数随 Bean 数线性涨,默认参数不改容易翻车 |
| 可观测性 | ⭐⭐⭐⭐ broker 端按 topic 看一切都很清楚 |
| 治理成本 | ⭐⭐ 必须配套监控和静态扫描,否则会失控 |
设计上没有银弹,只有取舍。@RemoteEvent 用 21 个线程换一个事件 Bean 的"零样板",赚不赚要看你的事件 QPS 分布、团队结构、运维能力。
但有一点是确定的:
不调默认参数 + 不做治理工具,无脑铺事件总线,最后一定会用一次线上事故来"教育"你。
至于这个设计怎么从一次 4000 线程吃光内存的事故里被反推出来的,详见 《从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战》。
给读者的小问题:如果让你自己设计一个事件总线,你会用 1 topic 1 事件,还是 1 topic N 事件按 tag 区分?两种方案的成本和收益分别是什么? 评论区聊。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)