让 Agent「先看规模再干活」------ 我给 DeepSeek Harness 写的 10x .h5 计数插件
一个只读 8 字节、毫秒级返回基因数/细胞数的 DSH 模型侧工具插件, 以及它背后那点「不读数据就能知道数据有多大」的小心思。
源码已开源 :Wang-Xinfu/h5-counts-plugin ------ 挂载版与含零依赖读取器的完整版都在仓库里
一、从一次尴尬的对话说起
做单细胞分析的人大概都经历过这样的对话:
我:帮我看一下这个 10x 矩阵有多大?
模型:好的,正在读取文件......
模型:(咔哒咔哒,读了半天)
模型:这个文件是 38.8 MB,有 134,920 个特征、2,711 个细胞。
问题在于:为了回答「多大」,模型把整个矩阵读了一遍 ------ 解压了 2451 万个非零元素。而它真正需要的答案,其实只存在文件头部的 8 个字节里。
matrix/shape 这个数据集只有两个整数:[n_genes, n_cells]。Cell Ranger 在写文件的第一时间就把它们写好了,矩阵本体(data / indices / indptr)反而被分块压缩、散落在文件各处。读取规模信息却要付出解压全量的代价,就像为了看电梯里的楼层牌,把整栋楼爬了一遍。
于是我给 DeepSeek Harness 写了一个工具插件:dsh-tool-h5-counts ,注册模型侧工具 get_cell_gene_counts。模型从此可以用一次工具调用、几十毫秒拿到矩阵规模,再决定后续要不要读全量、怎么过滤、怎么聚类。
二、插件是什么
less
dsh-tool-h5-counts(Cordis 插件)
└── name: tool-h5-counts
└── inject: ['tools']
└── apply(ctx)
└── ctx.tools.register(defineTool({
name: 'get_cell_gene_counts',
input : file_path // 10x .h5 矩阵文件路径
output: { genes, cells } // 行数(基因) × 列数(细胞)
}))
- 输入 :一个参数
file_path(绝对路径或相对会话工作区的路径)。 - 输出 :结构化 JSON
{ "genes": N, "cells": M },渲染为一行可读文本:get_cell_gene_counts: 134920 genes (rows) x 2711 cells (columns)。 - 行为 :只读
matrix/shape头部,不加载、不解压任何矩阵数据 ------ 这是全部设计的核心。
模型侧的使用方式也很自然------它自己决定什么时候调用,就像用 ls 先看看目录里有什么:
css
工具调用: get_cell_gene_counts(file_path="pbmc3k_filtered_feature_bc_matrix.h5")
返回 : {"genes": 134920, "cells": 2711}
模型 : 这个矩阵约 2.7k 细胞 × 13.5 万特征,规模不大,可以直接做标准 QC + 聚类流程......
三、为什么只读头部就够了
10x 的 .h5 是 HDF5 容器,标准结构如下:
bash
/filtered_feature_bc_matrix.h5
├── /matrix/
│ ├── shape # [n_genes, n_cells] ← 只读这一个数据集(2 个整数,8 字节)
│ ├── data # 非零表达值(chunked + deflate 压缩,几千万个)
│ ├── indices # 行索引(同 data 一样庞大)
│ └── indptr # 列指针
└── /matrix/features/* # 基因名、feature_type 等元数据
shape 是连续存储 (contiguous layout)的小数据集,位于文件前部;data/indices/indptr 是分块压缩(chunked + deflate/shuffle)的大数据集。所以:
| 做法 | 读取量 | 实测耗时(38.8 MB PBMC 3k) |
|---|---|---|
只读 matrix/shape(本插件) |
8 字节 | 35 ms |
读取完整矩阵(data+indices+indptr) |
2451 万个非零元素 | 520 ms |
基准数据为 10x Genomics 公开数据集 3k PBMCs from a Healthy Donor(Granulocytes removed through cell sorting) 的 Multiome(ATAC + 基因表达)版 (Cell Ranger ARC 2.0.0 处理,官网下载):文件 38.8 MB,矩阵 134,920 特征 × 2,711 细胞(36,601 个基因 + 98,319 个 ATAC peak),24,511,186 个非零元素。以上为进程内计时,Python 3.14 + h5py 3.16,不含解释器启动开销。另外用构造的 v0/v2/chunked/shuffle 各布局测试矩阵实测,头部读取在 0.77--2.12 ms。
注意这个差距是结构性的:全量读取耗时随非零元素数线性增长,几百万细胞的数据集要读几十 GB 的解压量;而头部读取是 O(1) 的,文件再大也还是毫秒级。这正是「让 Agent 先看规模」能成立的原因------代价几乎为零,收益是避免一次昂贵的全量误读。
四、两代实现:从「依赖 h5py」到「零依赖也能读」
这个工具我写过两个版本,正好体现了「先能跑,再跑得稳」的演进。
4.1 简单版:python -c + h5py
最初挂到 web profile 的版本(lib/index.js)只有几十行:用 execFile 调本机 python -c,让 h5py 打开文件读 matrix/shape,JSON 输出。h5py 是事实标准,能处理所有布局,天然正确。
js
const PY_SCRIPT = [
'import json, sys',
'import h5py',
"with h5py.File(sys.argv[1], 'r') as f:",
" shape = f['matrix/shape'][()]",
"print(json.dumps({'genes': int(shape[0]), 'cells': int(shape[1])}))",
].join('\n');
局限也很明显:依赖目标机器装了 python + h5py。换一台没有 h5py 的机器,工具就哑了。
4.2 完整版:内嵌零依赖 HDF5 读取器
于是有了第二个版本(complete/get_cell_gene_counts.plugin.js,721 行):内嵌一段独立的 Python 脚本,h5py 可用时优先用 h5py,否则退回纯标准库读取器,自己解析 HDF5 二进制格式:
- Superblock v0/v1 :老式布局,解析地址大小、基地址、根组符号表项;v2/v3 :现代布局,用
OHDR魔数探测两种偏移排布(有无保留字节),兼容 HDF5 1.10+ 与 1.14。 - v1 对象头 :对象头续接消息(0x0010)、符号表消息(0x0011)→ B-tree(
TREE)+ 符号节点(SNOD)+ 本地堆(HEAP)解析组层级;v2 对象头:按 flags 跳可选字段、每 4 条消息一个 gap、Link 消息(0x0006)取成员,遇到 fractal-heap 索引(0x0002)则明确报错并提示装 h5py。 - 数据集消息 :dataspace(v1/v2 两代编码)取维度;layout 支持 compact / contiguous / chunked 三类,兼容 HDF5 1.14 的 layout v3 偏移;filter pipeline 反向执行 deflate(zlib)+ shuffle 过滤器(按 chunk 的 filter mask 跳过未施加的过滤器)。
- Chunked B-tree :经典布局与 HDF5 1.14 布局(32 字节条目)按节点逐条校验识别;对
shape只需读第一个 chunk 的前 64 字节,然后立即停止。
工程细节上:
- 脚本经 stdin 流入
python -,文件路径走H5_PATH环境变量,彻底绕开 shell 转义和引号问题; - 宿主侧接入
fs.resolve/stat/processPath(解析相对路径、校验文件存在)、沙箱策略(越界文件明确报[sandbox: ...])、会话 cwd、exec.signal中止、60s 超时、stdout 上限; - 统一的错误契约:失败输出
{"error": "..."}并非零退出,宿主把 stderr 细节透传给模型。
读取器对每个字段都做合法性校验(魔数、地址范围、rank 上限、chunk 大小),不会因为一个坏文件把整个 Agent 进程带崩------这其实是给 LLM 用的工具最重要的素质:它要能优雅地说「我不行」。
五、怎么装进你的 DSH
以挂到 web profile 为例(profile 目录默认在 $env:DSH_HOME\profiles\web,流程与官方 dsh plugin 等价):
powershell
# 1) 克隆源码放进 profile 的 plugins 目录
git clone https://github.com/Wang-Xinfu/h5-counts-plugin.git $env:DSH_HOME\profiles\web\plugins\dsh-tool-h5-counts
# 2) 在 profile 的 package.json 声明依赖
# "dependencies": { "dsh-tool-h5-counts": "file:./plugins/dsh-tool-h5-counts" }
# 3) 在 profile 目录安装依赖(转发 pnpm)
dsh plugin --profile web install
# 4) 在 profile 的 cordis.patch.yml 插入插件行
# - insert:
# - id: tool-h5-counts
# name: dsh-tool-h5-counts
# 5) 重启 dsh web,新会话即可使用
运行依赖:本机 python;挂载版还需 h5py(pip install h5py),完整版 h5py 可选------没有时自动退回纯标准库读取器。若提示 tool "get_cell_gene_counts" is already registered in this scope(和 Harness 内置工具重名),把 defineTool 的 name 改成 h5_matrix_counts 即可。
六、测试:把「读不了的文件」也测了
开发时构造了一套测试矩阵(test_matrix_v0.h5 / test_matrix_v2.h5 / test_matrix_chunked.h5 / test_matrix_chunked_shuffle.h5,以及仿真的 real_*.h5),分别覆盖:
- superblock v0 与 v2(老/新文件格式);
- 连续存储与分块存储;
- 纯 deflate 压缩与 deflate + shuffle 组合。
有意思的是,其中一部分文件连 h5py 自己都会报 bad heap free list 打不开------这正是零依赖读取器存在的意义:它能读的布局,比 h5py 在部分异常文件上能读的还多,而且每次失败都会给出可操作的提示(「run 'pip install h5py' and retry」)。
七、写在最后
这个插件很小,但它是「给 Agent 设计工具」的一个不错样本:
- 把 O(n) 的问题变成 O(1) ------ 模型问规模,就别让它读全量;
- 能降级、能自救 ------ 有 h5py 用 h5py,没有就用纯标准库硬解析;
- 错误即信息 ------ 每个失败路径都告诉模型「差在哪、怎么修」;
- 接入要浅 ------ 纯 Cordis 插件、零运行时依赖,卸载即恢复。
下次你的 Agent 再遇到 .h5,它会在「要不要读这个 38 MB 的文件」之前,先花 35 毫秒问一句:这里面到底有多少细胞。
插件源码已开源:Wang-Xinfu/h5-counts-plugin ------ 挂载版 lib/index.js,含零依赖读取器的完整版 complete/get_cell_gene_counts.plugin.js。基准数据为 10x Genomics 公开数据集 3k PBMCs from a Healthy Donor(Granulocytes removed through cell sorting) 的 Multiome 版(官方下载)。