1. 项目概述:为什么多维聚合不是“加个groupby”那么简单

我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到后来带团队重构整个风险指标计算引擎,踩过的坑比写的代码还多。今天聊的这个主题——“多维聚合中的数据操作”,听起来像教科书里的一个章节标题,但实际在生产环境里,它直接决定着风控模型能不能按时上线、月度经营分析报告能不能准时发给CEO、甚至某次大促期间实时交易监控告警是否误报。这不是炫技,是每天都在发生的、带着业务体温的硬需求。

核心关键词就三个: 多维聚合、滚动计算、业务可解释性 。你可能已经会用 df.groupby('region')['revenue'].sum() ,但当业务方突然甩来一句:“把华东区餐饮类目下TOP3商户的近7天日均流水、环比变化、以及单笔交易金额的标准差,按周粒度拆开,再和去年同期比”,这时候光靠基础groupby就彻底卡死。问题不在语法,而在思维——你得同时处理 维度交叉、时间滑窗、统计口径混合、结果可读性 这四层嵌套逻辑。而这些,恰恰是金融、零售、SaaS运营等强分析场景的日常。

我见过太多团队把这类需求硬塞进BI工具拖拽界面,结果报表跑半小时、改个字段要重刷全量、业务同事看不懂列名里那一长串 revenue_mean_rolling_7d_std 到底代表什么。也见过用纯SQL硬刚的,一个指标写三张临时表,ETL任务一崩就是整条链路延迟。真正稳的方案,是用pandas构建一套 语义清晰、计算高效、结果即用 的聚合骨架。它不追求“最短代码”,而追求“下次业务提同样需求时,我5分钟改完参数就能交付”。这篇文章讲的,就是我们团队在信用卡反欺诈系统、对公客户价值评估模型、跨境支付手续费分析平台中反复验证过的七种落地模式——全部来自真实日志、真实报错、真实上线时间点。

重点说一句:所有示例代码都经过2024年Q3最新版pandas 2.2.x实测(非教程截图),函数签名、返回结构、空值处理逻辑全部对齐生产环境。你复制粘贴就能跑,但更关键的是每段代码背后那个“为什么这么写”的判断依据——比如为什么 rolling().mean() 后面必须跟 reset_index(level=0, drop=True) ,为什么 unstack() 前一定要确认索引层级顺序,为什么自定义函数里要显式处理 len(series) < 2 的边界情况。这些细节,才是区分“能跑通”和“敢上生产”的分水岭。

2. 核心设计思路:从“算得出来”到“算得明白、算得稳”

2.1 为什么放弃SQL窗口函数?——生产环境的隐性成本

很多人第一反应是用SQL写 OVER (PARTITION BY region, category ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) 。语法没错,但在我们实际运维的三个典型场景中,它成了瓶颈:

  • 调度依赖太重 :银行T+1数据入仓后,SQL脚本要等上游ODS层完全落库才能启动,而pandas脚本可以直接接Hive表或Parquet文件,凌晨2点数据就绪,2:03就开始跑聚合;
  • 调试成本高 :SQL里一个 NULL 值没处理好,整张结果表全变 NULL ,查起来要翻十几层CTE;pandas里 print(df.head()) 一眼看到底, df['amount'].isna().sum() 秒出问题量级;
  • 业务逻辑耦合深 :风控要求“近7天平均值只计有效交易(排除退单)”,SQL里得加 WHERE status != 'REFUNDED' ,但业务规则下周可能变成“退单满3笔才剔除”,每次改SQL都要走审批流程;而pandas里把过滤逻辑封装成 filter_valid_transactions() 函数,版本管理、A/B测试、回滚都是一行命令的事。

所以我们的技术选型原则很朴素: 能用向量化操作解决的,绝不循环;能用原生pandas API的,绝不拼SQL;能用函数封装业务规则的,绝不写死条件 。这直接决定了后续所有聚合模式的设计起点。

2.2 多维聚合的本质:不是“分组”,而是“构建分析坐标系”

新手常把 groupby(['region','product']) 理解为“先按region分,再按product分”,这在技术实现上没错,但业务视角完全错了。真实场景中,region和product是 正交维度 ——就像地图的经纬度,你不能说“先画经线再画纬线”,而应该理解为“每个region-product组合定义了一个独立分析单元”。这个认知差异直接导致两种写法:

# ❌ 错误认知:当成嵌套分组(易引发索引混乱)
df.groupby('region').groupby('product')  # 报错!groupby不能链式调用

# ✅ 正确认知:构建多维坐标(结果天然支持unstack)
result = df.groupby(['region','product'])['revenue'].mean()
# result是Series,index是MultiIndex:(North, Widget), (North, Gadget)...

这种坐标系思维带来三个关键优势:

  • 结果可预测 :无论你后续用 unstack() 转宽表、 xs() 切片取子集、还是 reindex() 对齐基准期,索引结构始终稳定;
  • 扩展性强 :明天业务要加“渠道来源”维度?只需把 ['region','product'] 改成 ['region','product','channel'] ,其余代码零修改;
  • 错误可追溯 :当 unstack() 后某列全是NaN,立刻知道是 (South, Gadget) 组合在原始数据中根本不存在,而不是某个计算步骤出了bug。

2.3 滚动与扩展窗口的生死线:时间敏感型计算的容错设计

滚动窗口(rolling)和扩展窗口(expanding)看似只是 window=7 min_periods=1 的区别,但在生产环境里,它们对应着完全不同的业务语义和错误容忍度:

场景 滚动窗口(如7日均值) 扩展窗口(如YTD累计)
业务含义 “最近一周表现”,强调时效性 “从年初至今累计”,强调完整性
空值容忍度 高(前6天可为NaN,业务接受) 极低(第1天就该有值,NaN=数据缺失)
典型错误 min_periods=1 导致首日均值失真 expanding().sum() 未重置索引致结果错位

我们在线上系统强制规定: 所有滚动计算必须显式声明 min_periods ,所有扩展计算必须紧接 reset_index() 。这不是代码洁癖,而是避免某次数据延迟导致风控模型误判——去年某次因 rolling(window=30).mean() 没设 min_periods=15 ,月初15天全返回NaN,触发了假阳性预警,损失了2小时人工核查时间。

3. 实操细节解析:七种模式逐层拆解(含避坑清单)

3.1 多列多函数聚合:告别merge,拥抱字典映射

基础需求:财务要各商户类别的交易额均值和中位数,运营要手续费的最小值和最大值。新手常写两遍groupby再merge:

# ❌ 低效且易错(索引对齐风险高)
mean_df = df.groupby('merchant_category')['transaction_amount'].mean()
max_fee_df = df.groupby('merchant_category')['processing_fee'].max()
result = pd.merge(mean_df, max_fee_df, left_index=True, right_index=True)

正确姿势是用字典一次声明:

# ✅ 生产级写法(注意列名嵌套结构)
result = df.groupby('merchant_category').agg({
    'transaction_amount': ['mean', 'median'], 
    'processing_fee': ['min', 'max']
})

关键细节

  • 输出是 DataFrame ,列名为 MultiIndex :外层是原始列名( transaction_amount ),内层是聚合函数( mean )。打印 result.columns 你会看到 Index([('transaction_amount', 'mean'), ('transaction_amount', 'median'), ...])
  • 如果下游系统(如Excel、BI工具)不支持多级列名,必须扁平化:
    result.columns = ['_'.join(col).strip() for col in result.columns]
    # 得到:['transaction_amount_mean', 'transaction_amount_median', ...]
    
  • 致命陷阱 :若某列参与聚合的值全为NaN,对应结果会是 NaN 而非 0 。财务报表中“均值为NaN”和“均值为0”意义天壤之别。必须在agg前清洗:
    df['transaction_amount'] = df['transaction_amount'].fillna(0)  # 或用业务规则填充
    

提示:银行风控系统中,我们约定所有金额类字段的NaN统一替换为 -999999 (业务侧明确这是“数据缺失”标记,非真实负值),避免统计函数误判。

3.2 自定义聚合函数:让业务逻辑“活”在代码里

标准函数解决不了的问题:

  • 风控要求的“交易金额范围”(max-min),但需排除异常值(如单笔超50万的测试数据);
  • 运营要的“加权平均交易额”,近期交易权重更高(体现消费趋势);
  • 合规需要的“高风险交易占比”,定义为“单笔>300元且发生在非营业时间(22:00-06:00)的交易数/总交易数”。

正确写法分三步

  1. 写纯函数(无pandas依赖)

    def risk_transaction_ratio(series, time_series=None, threshold=300):
        """
        计算高风险交易占比
        series: 交易金额序列
        time_series: 对应的时间序列(需外部传入,因agg不支持多列传参)
        """
        if time_series is None:
            raise ValueError("time_series must be provided")
        # 构建布尔掩码:金额超阈值 AND 时间在非营业时段
        hour_mask = ((time_series.dt.hour >= 22) | (time_series.dt.hour < 6))
        amount_mask = series > threshold
        risky_count = (hour_mask & amount_mask).sum()
        return (risky_count / len(series) * 100).round(2) if len(series) > 0 else 0
    
  2. apply() 而非 agg() 调用(因需多列)

    # 注意:apply作用于分组后的DataFrame,可访问多列
    result = df.groupby('customer_id').apply(
        lambda x: pd.Series({
            'risk_pct': risk_transaction_ratio(
                x['amount'], 
                x['transaction_time'],  # 传入时间列
                threshold=300
            ),
            'avg_amount': x['amount'].mean()
        })
    )
    
  3. 关键避坑

    • agg() 只能对单列操作, apply() 可对整个分组DataFrame操作,但性能略低(大数据量慎用);
    • 自定义函数内 禁止修改原始DataFrame (如 x['new_col'] = ... ),否则引发不可预知错误;
    • 函数必须有明确返回值, None 会导致结果列全为NaN。

实操心得:我们团队把所有业务规则函数放在 business_rules.py 中,用 @lru_cache 装饰器缓存计算结果。某次将“地区GDP系数”作为参数传入时,发现未缓存导致每日重复查API,QPS暴增3倍——现在所有外部依赖都强制走缓存。

3.3 滚动窗口计算:时间序列的“滑动标尺”

典型场景:信用卡中心要监控“近7天单日交易额均值”,用于识别突发性盗刷。代码看似简单:

df_ts['rolling_7d'] = df_ts.groupby('category')['daily_revenue'].rolling(window=7).mean()

但生产环境必须处理四个魔鬼细节:

细节1:索引重置
rolling().mean() 返回的是 Series ,其索引是 MultiIndex (包含分组键和原始索引),直接赋值会报错或错位。必须用 reset_index(level=0, drop=True) 剥离分组键:

# ✅ 正确(level=0指第一层索引,即category)
df_ts['rolling_7d'] = df_ts.groupby('category')['daily_revenue'].rolling(window=7).mean().reset_index(level=0, drop=True)

细节2:空值策略
min_periods 参数决定最少需要几个有效值才计算。 min_periods=1 会让首日就有值(等于自身),但业务上“第一天的7日均值”毫无意义。我们统一设为 min_periods=4 (至少半周数据才可信):

df_ts['rolling_7d'] = df_ts.groupby('category')['daily_revenue'].rolling(
    window=7, min_periods=4
).mean().reset_index(level=0, drop=True)

细节3:时间排序强制校验
滚动计算默认按索引顺序,但若数据未按时间排序,结果完全错误。必须在rolling前加 sort_values()

df_ts = df_ts.sort_values(['category', 'date']).set_index('date')
# 然后再rolling...

细节4:性能优化
对亿级交易数据, rolling().mean() 会内存爆炸。改用 numba 加速:

from numba import jit
@jit(nopython=True)
def fast_rolling_mean(arr, window):
    result = np.empty(len(arr))
    for i in range(len(arr)):
        if i < window - 1:
            result[i] = np.nan
        else:
            result[i] = np.mean(arr[i-window+1:i+1])
    return result

注意: numba 不支持pandas对象,需先 arr = df_ts['daily_revenue'].values 转numpy数组。

3.4 扩展窗口计算:从“当前点”回溯的累积逻辑

扩展窗口( expanding() )常被误认为“滚动窗口的特例”,其实业务语义截然不同:

  • 滚动窗口回答:“最近N天怎么样?”(动态窗口);
  • 扩展窗口回答:“从开始到现在怎么样?”(固定起点,终点推进)。

致命陷阱:索引错位
看这段常见错误代码:

# ❌ 错误:expanding()后索引仍是MultiIndex,直接赋值错位
df_ts['cumulative_sum'] = df_ts.groupby('category')['daily_revenue'].expanding().sum()

正确做法必须重置索引:

# ✅ 正确:两次reset_index确保对齐
cumulative_series = df_ts.groupby('category')['daily_revenue'].expanding().sum()
# 第一次:重置分组索引(level=0)
cumulative_series = cumulative_series.reset_index(level=0, drop=True)
# 第二次:重置原始索引(因expanding()可能改变顺序)
cumulative_series = cumulative_series.reindex(df_ts.index)
df_ts['cumulative_sum'] = cumulative_series

业务增强 :银行YTD报表要求“截至当日的累计值”,但需排除节假日。我们封装函数:

def ytd_cumulative(series, date_index, holidays=None):
    """带节假日过滤的YTD累计"""
    if holidays is None:
        holidays = ['2024-01-28', '2024-02-10']  # 示例
    mask = ~date_index.strftime('%Y-%m-%d').isin(holidays)
    return series[mask].expanding().sum().reindex(date_index)

df_ts['ytd_revenue'] = ytd_cumulative(
    df_ts['daily_revenue'], 
    df_ts.index,
    holidays=['2024-01-28', '2024-02-10']
)

3.5 多级分组与unstack:把“表格思维”翻译成“代码思维”

业务需求:“各区域各产品线的平均收入对比表”,老板要直接粘贴进PPT。 unstack() 是终极答案,但新手常栽在索引层级上。

正确流程

  1. 先确认分组后索引结构:

    grouped = df_sales.groupby(['region','product'])['revenue'].mean()
    print(grouped.index)  # MultiIndex([('North', 'Widget'), ('North', 'Gadget'), ...])
    

    索引有两层: region (level=0)、 product (level=1)。

  2. unstack() 默认展开最后一层(level=-1),即 product

    result = grouped.unstack('product')  # 显式指定更安全
    # 或 result = grouped.unstack(level=1)
    
  3. 处理缺失值:若某区域无某产品数据,unstack后为NaN。业务要求填0:

    result = grouped.unstack('product', fill_value=0)
    

高级技巧:多层unstack
若分组是 ['region','product','quarter'] ,想得到“区域×产品”矩阵,每格是季度列表:

# 先按region,product分组,聚合为list
grouped_list = df_sales.groupby(['region','product'])['revenue'].apply(list)
# 再unstack product层
result = grouped_list.unstack('product')

注意: unstack() 后若列名含空格或特殊字符,BI工具可能无法识别。我们约定列名清洗: result.columns = [col.replace(' ', '_') for col in result.columns]

3.6 综合实战:信用卡客户分析七步法(附完整可运行代码)

以下是我们线上使用的 credit_card_analyzer.py 精简版,已去除公司敏感逻辑,保留全部工程细节:

import pandas as pd
import numpy as np
from datetime import datetime

class CreditCardAnalyzer:
    def __init__(self, data):
        self.df = data.copy()
        # 强制类型转换,避免后期计算异常
        self.df['date'] = pd.to_datetime(self.df['date'])
        self.df['amount'] = pd.to_numeric(self.df['amount'], errors='coerce')
        self.df['fee'] = pd.to_numeric(self.df['fee'], errors='coerce')
    
    def analysis_1_multi_agg(self):
        """多列多函数聚合:客户-类目级统计"""
        return self.df.groupby(['customer_id','category']).agg({
            'amount': ['mean', 'median', 'count'],
            'fee': ['min', 'max']
        }).round(2)
    
    def analysis_2_custom_range(self):
        """自定义范围:各商户类目交易额波动性"""
        def transaction_range(series):
            if len(series) < 2:
                return 0
            return series.max() - series.min()
        
        return self.df.groupby('category').agg({
            'amount': [transaction_range, 'std']
        }).round(2)
    
    def analysis_3_rolling_avg(self, window=7):
        """滚动均值:按客户计算"""
        df_sorted = self.df.sort_values(['customer_id','date']).set_index('date')
        # 关键:按customer_id分组后rolling,再重置索引
        rolling_series = df_sorted.groupby('customer_id')['amount'].rolling(
            window=window, min_periods=4
        ).mean().reset_index(level=0, drop=True)
        # 重新对齐原始索引
        rolling_series = rolling_series.reindex(df_sorted.index)
        return pd.DataFrame({
            'customer_id': df_sorted['customer_id'],
            'date': df_sorted.index,
            'amount': df_sorted['amount'],
            'rolling_avg': rolling_series
        })
    
    def analysis_4_cumulative_spend(self):
        """累计消费:按客户"""
        df_sorted = self.df.sort_values(['customer_id','date']).set_index('date')
        cum_series = df_sorted.groupby('customer_id')['amount'].expanding().sum()
        cum_series = cum_series.reset_index(level=0, drop=True).reindex(df_sorted.index)
        return pd.DataFrame({
            'customer_id': df_sorted['customer_id'],
            'date': df_sorted.index,
            'amount': df_sorted['amount'],
            'cumulative_spend': cum_series
        })
    
    def analysis_5_crosstab(self):
        """交叉分析:客户vs类目均值"""
        return self.df.groupby(['customer_id','category'])['amount'].mean().unstack(
            fill_value=0
        ).round(2)
    
    def analysis_6_executive_summary(self):
        """高管摘要:客户级核心指标"""
        summary = self.df.groupby('customer_id').agg({
            'amount': ['sum', 'mean', 'count'],
            'fee': 'sum'
        }).round(2)
        # 扁平化列名
        summary.columns = ['total_spend', 'avg_transaction', 'transaction_count', 'total_fees']
        summary['avg_fee_percent'] = ((summary['total_fees'] / summary['total_spend']) * 100).round(2)
        return summary
    
    def analysis_7_risk_segmentation(self, high_value_threshold=300):
        """风险分层:高价值交易识别"""
        def risk_metrics(series):
            if len(series) == 0:
                return pd.Series({'high_value_count': 0, 'high_value_pct': 0.0, 'regular_avg': 0})
            high_count = (series > high_value_threshold).sum()
            return pd.Series({
                'high_value_count': high_count,
                'high_value_pct': round(high_count / len(series) * 100, 1),
                'regular_avg': series[series <= high_value_threshold].mean() if high_count < len(series) else 0
            })
        
        return self.df.groupby('customer_id')['amount'].apply(risk_metrics)

# 使用示例(生成模拟数据)
np.random.seed(42)
customers = ['C001','C002','C003'] * 20
categories = np.random.choice(['Groceries','Dining','Travel','Retail'], 60)
amounts = np.random.uniform(20, 500, 60).round(2)
dates = pd.date_range('2024-01-01', periods=60, freq='D')
df_sample = pd.DataFrame({
    'date': np.resize(dates, 60),
    'customer_id': customers,
    'category': categories,
    'amount': amounts,
    'fee': (amounts * 0.025).round(2)
})

# 执行分析
analyzer = CreditCardAnalyzer(df_sample)
print("=== 分析1:客户-类目统计 ===")
print(analyzer.analysis_1_multi_agg())
print("\n=== 分析2:类目交易范围 ===")
print(analyzer.analysis_2_custom_range())

关键工程实践

  • 所有方法返回 DataFrame Series ,不修改原始 self.df (函数式编程原则);
  • 每个方法有明确文档字符串,说明输入输出、业务含义、边界处理;
  • 初始化时强制类型转换( pd.to_datetime , pd.to_numeric ),避免后续 rolling() 报错;
  • analysis_3_rolling_avg() min_periods=4 是业务共识,写死而非参数化,防止误配。

4. 常见问题排查手册:那些让你加班到凌晨的Bug

4.1 “结果全NaN”问题速查表

现象 最可能原因 排查命令 解决方案
agg() 后某列全NaN 该列存在大量NaN且未设置 min_count df['amount'].isna().sum() agg(..., min_count=1) 或提前 fillna()
rolling().mean() 全NaN 数据未按时间排序 df['date'].is_monotonic_increasing df.sort_values('date', inplace=True)
unstack() 后全NaN 分组组合在数据中不存在(如South-Gadget) df.groupby(['region','product']).size() unstack(fill_value=0) 或补全缺失组合
expanding().sum() 首行NaN expanding() 默认 min_periods=1 但数据有空值 df['amount'].head(10) expanding(min_periods=1) + fillna(method='bfill')

4.2 性能瓶颈定位与优化

当聚合耗时超预期,按此顺序排查:

  1. 检查数据量级 len(df) 是否超千万?超则必须采样分析:
    sample_df = df.sample(frac=0.1, random_state=42)  # 先用10%数据验证逻辑
    
  2. 检查分组键基数 df['customer_id'].nunique() 若超百万, groupby 会内存爆炸:
    • 方案A:改用 dask.dataframe (适合单机多核);
    • 方案B:按 customer_id 哈希分片, df.groupby(df['customer_id'].str.slice(0,2)) 先粗分;
  3. 检查自定义函数 :用 %timeit 测试单次执行:
    %timeit risk_metrics(df_sample[df_sample['customer_id']=='C001']['amount'])
    
    若>10ms,必须用 numba 或向量化重写。

4.3 空值地狱:生产环境的终极挑战

空值不是“没有数据”,而是“业务状态未明”。我们制定三条铁律:

  • 输入层清洗 :所有ETL任务末尾加 assert df.isna().sum().sum() == 0, "NaN detected!"
  • 计算层防御 agg() 前加 df.fillna({'amount':0, 'fee':0}) ,但金额类用 -999999 标记缺失;
  • 输出层校验 result.isna().sum().sum() > 0 则触发告警,邮件通知负责人。

去年某次因上游漏传 transaction_time ,导致 risk_transaction_ratio() 全返回0,风控模型失效12小时。现在所有时间类字段强制 fillna(pd.Timestamp('1970-01-01')) ,函数内加 if series.min() == pd.Timestamp('1970-01-01'): raise ValueError("Time missing")

5. 工程化落地建议:从Notebook到生产服务

5.1 代码组织规范

我们团队的 aggregation_utils/ 目录结构:

aggregation_utils/
├── __init__.py
├── base.py              # BaseAnalyzer类,含通用清洗、校验方法
├── finance/             # 金融行业专用
│   ├── credit_risk.py   # 信用卡风控聚合
│   └── treasury.py      # 资金头寸聚合
├── retail/              # 零售行业专用
│   └── sales_analytics.py
└── utils/               # 工具函数
    ├── time_windows.py  # 滚动/扩展窗口工厂函数
    └── column_flatten.py # 列名扁平化工具

所有模块通过 pip install -e . 安装,Jupyter中直接 from aggregation_utils.finance import credit_risk

5.2 测试驱动开发(TDD)实践

每个聚合函数必须有三类测试:

  • 单元测试 :验证单个函数逻辑(如 transaction_range([100,200,300]) == 200 );
  • 集成测试 :验证 CreditCardAnalyzer 全流程(用固定seed生成数据,断言输出shape和关键值);
  • 回归测试 :保存历史结果快照,每次更新后比对 result.equals(old_result)

CI流程中,任一测试失败则阻断发布。

5.3 监控与告警

线上服务中,我们埋点监控三个黄金指标:

  • 计算耗时 time.time() 包裹 analyzer.run_all() ,超5秒告警;
  • 结果完整性 result.isna().sum().sum() ,非零立即告警;
  • 业务合理性 :如 'total_spend' 列最大值超1000万,触发人工复核(防数据污染)。

告警信息包含: [AGG-ERROR] customer_id=C001, analysis=rolling_avg, duration=8.2s, nan_count=15 ,运维可秒级定位。

6. 我的实战体会:少些炫技,多些敬畏

写这篇总结时,我翻出了2018年第一版信用卡分析脚本——300行混杂SQL和pandas,没有函数封装,注释写着“此处逻辑待优化(勿动)”。如今同一需求,核心聚合逻辑压缩到80行,有完整测试,有监控告警,有业务文档。进步不在技术,而在认知: 数据聚合不是技术问题,是业务翻译问题

我坚持让每个自定义函数名直白如业务术语( risk_transaction_ratio 而非 calc_xxx ),坚持在 unstack() 后加 fill_value=0 而非留NaN,坚持把 min_periods=4 写死而非参数化——因为业务规则不该由工程师“灵活配置”,而应由产品经理签字确认。

最后分享个小技巧:当业务方提出新需求,我第一句话永远是“这个指标用来做什么决策?如果算错了,最坏影响是什么?”——问清这个,80%的需求会自己简化。毕竟,我们不是在写代码,是在构建业务决策的神经突触。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐