🏗️三层管线架构总览

将一个 AI 蛋白质结构预测模型部署到生产环境,远不止 model.predict() 一行代码。真实的计算管线是一条数据格式不断变换、计算资源严格管控、失败需要优雅恢复的工程链路。本文拆解的管线分为三层,每一层有明确的输入输出契约和独立的工程挑战:

层级输入引擎输出核心工程挑战
结构预测氨基酸序列 + 配体 CCDESMFold2-Fast (ESMC 6B)mmCIF 全原子坐标GPU 内存治理、OOM 恢复
格式转换 + 对接mmCIF 文件BioPython + RDKit + MeekoPDBQT + Vina 配置格式保真、原子类型正确
分子动力学PDB 复合物结构OpenMM + Amber14 + OpenFFDCD 轨迹 + 能量日志配体约束、检查点恢复

这三层之间通过文件系统解耦——每一层将结果写入标准结构文件(CIF/PDB/PDBQT),下一层从文件读取输入。这种设计带来三个工程优势:(1) 每层可独立调试和重跑;(2) 中间结果可检查和可视化;(3) 不同层可以使用不同的 Python 环境(预测层用 Python 3.12 + vendored esm,MD 层用 conda openmmdl 环境)。

下面逐层拆解。需要强调的是,本文聚焦工程模式而非具体生物学靶点——这些模式可以迁移到任何蛋白质-配体系统的结构预测、对接和动力学模拟工作流。

💾模型资产与本地化部署

ESMFold2 的模型资产远不止一个权重文件。完整的生产部署需要管理三类资产:模型权重化学组件字典依赖代码

权重文件层次

资产大小角色
model.safetensors~940 MBESMFold2-Fast 折叠层权重
esmc-6b/ (6 shards)~25 GBESMC 6B 蛋白质语言模型骨干
ccd.pkl~417 MBChemical Component Dictionary(pickle 序列化)
config.json~2.4 KBESMFold2 架构超参数
esmc-6b/config.json~341 BESMC 骨干架构(80层, d=2560, 40头)

ESMFold2 的架构是双模型设计:ESMC 6B 负责从氨基酸序列提取高维表征(每个残基 2560 维向量),ESMFold2 折叠层负责将这些表征转换为全原子 3D 坐标。两个模型的权重分开存储,通过 config.json 中的 esmc_id 字段关联:

// config.json (ESMFold2)
{
  "architectures": ["ESMFold2Model"],
  "esmc_id": "/path/to/esmc-6b",    // 指向 ESMC 骨干目录
  "lm_d_model": 2560,                // ESMC 表征维度
  "lm_num_layers": 80,               // ESMC Transformer 层数
  "folding_trunk": {
    "n_layers": 48,                  // 折叠主干层数
    "n_heads": 8
  },
  "structure_head": {
    "diffusion_module": {
      "c_atom": 128,                 // 原子级表征维度
      "c_token": 768,                // token 级表征维度
      "token_num_blocks": 12,        // 扩散模块 token 块数
      "inference_num_steps": 14      // 推理扩散步数
    }
  }
}

本地 CCD 缓存:离线推理的关键

ESMFold2 支持小分子配体输入,需要通过 PDB 的 Chemical Component Dictionary (CCD) 将三字母配体代码(如 "GLN""SAH")解析为原子坐标和键拓扑。默认行为是从 RCSB 网络下载,但在生产环境中这是不可接受的延迟和故障点

解决方案是将整个 CCD 预序列化为 pickle 文件并设置环境变量:

# 在导入任何 esm 模块之前设置
import os
os.environ['ESMCFOLD_CCD_PATH'] = os.path.join(BASE_DIR, 'ccd.pkl')

# 417 MB 的 ccd.pkl 包含全部 PDB 化学组件
# 配体解析从网络请求变为内存查找,延迟从秒级降到微秒级

这个细节看似简单,但在批量预测场景下(如虚拟筛选数百个配体),网络下载会成为管线瓶颈。本地 CCD 缓存是实现确定性推理时间的前提。

🎛️GPU 内存治理:预检、分块、降级

ESMFold2 的 ESMC 6B 骨干在 fp32 下需要约 24 GB 显存,加上折叠层和中间激活,单次预测的峰值显存需求可达 30+ GB。在 A100 40GB 上,这意味着没有太多余量——如果 GPU 上有其他进程占用了几 GB,预测就会 OOM。

生产代码实现了一套三阶段 GPU 内存治理策略:

阶段一:预检(Pre-flight Check)

在加载模型之前,检查 GPU 可用显存是否满足最低要求。如果不满足,立即失败而不是在模型加载中途 OOM(后者会留下难以清理的 GPU 碎片):

def ensure_cuda_memory(min_free_gb: float) -> None:
    if min_free_gb <= 0 or not torch.cuda.is_available():
        return
    free, total = torch.cuda.mem_get_info()
    free_gb = free / 1024**3
    if free_gb < min_free_gb:
        raise RuntimeError(
            f"CUDA free memory is {free_gb:.2f}/{total/1024**3:.2f} GiB, "
            f"below --min-free-gb={min_free_gb:.2f}. Lower parameters, "
            "choose another GPU, or wait for other jobs."
        )

默认阈值为 24 GiB。这个值不是随意设定的——它对应 ESMC 6B fp32 权重的显存占用下限。预检让运维团队能够通过监控脚本在预测启动前就判断资源是否充足,而不是在 5 分钟的模型加载后才发现 OOM。

阶段二:分块(Chunking)

折叠主干中的 pair state 更新涉及 L² 复杂度的注意力计算(L 为序列长度)。对于长序列(如 469 残基),这会产生巨大的中间张量。ESMFold2 提供 set_chunk_size(n) 接口,将 L² 操作分块执行:

model = ESMFold2Model.from_pretrained(BASE_DIR, torch_dtype=dtype, low_cpu_mem_usage=True)
model = model.to(device).eval()
model.set_chunk_size(chunk_size)  # chunk_size=16 时峰值显存显著降低

chunk_size 的选择是显存-速度权衡:chunk=16 最省显存但最慢,chunk=64(或 -1 禁用分块)最快但显存需求最高。生产环境默认 chunk=16(low_mem 预设),在有足够显存时可以提升到 48 或 64。

阶段三:OOM 自动降级

即使有预检和分块,仍可能因 GPU 状态波动而 OOM。生产代码实现了自动降级重试——捕获 torch.cuda.OutOfMemoryError,用更保守的参数重新加载模型:

try:
    result = fold(model, spi, params, seed)
except torch.cuda.OutOfMemoryError:
    if args.no_oom_retry or device != "cuda":
        raise
    print("CUDA OOM detected. Retrying with safer low-memory parameters...")
    del model
    clear_cuda_cache()  # gc.collect() + empty_cache() + ipc_collect()

    retry_params = FoldParams(
        num_loops=0,                                    # 跳过循环精化
        num_sampling_steps=max(8, min(12, params.num_sampling_steps)),
        num_diffusion_samples=1,
        chunk_size=16,                                  # 最小分块
        dtype=torch.float32,                            # fp32(避免 bf16 的额外激活)
    )
    model = load_model(device, retry_params.dtype, retry_params.chunk_size)
    result = fold(model, spi, retry_params, args.seed)

这个降级策略的关键设计是:loops=0(跳过循环精化,这是最耗显存的阶段)、sampling_steps 压缩到 8-12(从 20-50 降级)、chunk_size=16(最小分块)。降级后的预测精度会降低,但至少能产出结果而不是完全失败——在生产环境中,"有结果但精度低"远优于"无结果"。

显存监控全链路

生产代码在管线的每个关键节点打印显存状态,便于事后分析:

def print_cuda_memory(prefix: str) -> None:
    allocated = torch.cuda.memory_allocated() / 1024**3
    reserved = torch.cuda.memory_reserved() / 1024**3
    free, total = torch.cuda.mem_get_info()
    print(f"{prefix}: allocated={allocated:.2f} GiB, "
          f"reserved={reserved:.2f} GiB, "
          f"free={free/1024**3:.2f}/{total/1024**3:.2f} GiB")

# 调用点:Before model load → After model load → After prediction

此外,PyTorch 分配器配置也做了优化,减少碎片:

os.environ.setdefault(
    "PYTORCH_CUDA_ALLOC_CONF",
    "expandable_segments:True,max_split_size_mb:128"
)

expandable_segments:True 让 CUDA 分配器动态扩展段而不是预分配大块,max_split_size_mb=128 限制单个分块大小,两者共同减少碎片——这在多次预测的批量场景下尤为重要。

🔌CPU 亲和性:生产环境的资源隔离

在一个 64 核服务器上运行 ESMFold2 预测,如果不做 CPU 限制,PyTorch/BLAS 默认会使用所有核心。这在共享服务器上是灾难性的——预测进程会挤占其他服务(如 Web 服务、数据库、监控)的 CPU 资源。

生产代码实现了一套CPU 资源隔离方案,在导入任何计算库之前就设置好限制:

def default_cpu_threads(reserve_cores: int = 10) -> int:
    return max(1, (os.cpu_count() or 1) - reserve_cores)

# 在导入 torch/numpy 之前设置 BLAS 线程数
DEFAULT_CPU_THREADS = default_cpu_threads()
for env_name in (
    "OMP_NUM_THREADS",      # OpenMP
    "MKL_NUM_THREADS",      # Intel MKL
    "OPENBLAS_NUM_THREADS", # OpenBLAS
    "NUMEXPR_NUM_THREADS",  # NumExpr
    "VECLIB_MAXIMUM_THREADS", # Accelerate
):
    os.environ.setdefault(env_name, str(DEFAULT_CPU_THREADS))

默认保留 10 个核心给系统——在 64 核服务器上,预测进程使用 54 核,留 10 核给其他服务。这个设计的关键在于时机:这些环境变量必须在 import torch 之前设置,因为 PyTorch 在导入时读取它们来初始化 BLAS 后端。

除了环境变量,代码还使用 OS 级别的 CPU 亲和性强制执行:

def apply_cpu_limits(args):
    available_cpus = sorted(os.sched_getaffinity(0))
    requested_threads = args.cpu_threads or max(1, len(available_cpus) - args.reserve_cpu_cores)
    cpu_threads = max(1, min(requested_threads, len(available_cpus)))

    # PyTorch 线程池
    torch.set_num_threads(cpu_threads)
    torch.set_num_interop_threads(max(1, min(4, cpu_threads)))

    # OS 级亲和性绑定
    if not args.no_cpu_affinity and hasattr(os, "sched_setaffinity"):
        os.sched_setaffinity(0, set(available_cpus[:cpu_threads]))

sched_setaffinity 是 Linux 的硬性 CPU 绑定——进程只能在指定核心上调度,即使其他核心空闲也不会迁移。这确保了资源隔离的可预测性:运维可以精确知道哪些核心属于预测进程、哪些属于系统。

这套方案通过 --reserve-cpu-cores--cpu-threads 两个参数暴露给用户,灵活适应不同服务器配置:

场景参数效果
共享服务器(默认)--reserve-cpu-cores 10保留 10 核给系统
专用服务器--reserve-cpu-cores 2仅保留 2 核
精确控制--cpu-threads 32固定使用 32 核
禁用亲和性--no-cpu-affinity仅限制线程数,不绑核

⚙️参数预设:精度-内存权衡框架

ESMFold2 的预测质量由三个核心参数控制:num_loops(循环精化次数)、num_sampling_steps(扩散采样步数)、num_diffusion_samples(扩散样本数)。这些参数与显存占用和计算时间直接相关。生产代码将常见配置抽象为三个预设

@dataclass(frozen=True)
class FoldParams:
    num_loops: int
    num_sampling_steps: int
    num_diffusion_samples: int
    chunk_size: int | None
    dtype: torch.dtype

PRESETS = {
    "low_mem": FoldParams(
        num_loops=1, num_sampling_steps=20,
        num_diffusion_samples=1, chunk_size=16,
        dtype=torch.float32,
    ),
    "balanced": FoldParams(
        num_loops=2, num_sampling_steps=35,
        num_diffusion_samples=1, chunk_size=48,
        dtype=torch.bfloat16,
    ),
    "quality": FoldParams(
        num_loops=3, num_sampling_steps=50,
        num_diffusion_samples=1, chunk_size=64,
        dtype=torch.float32,
    ),
}
预设LoopsStepsChunkDtype适用场景
low_mem12016fp32GPU 显存受限(默认)
balanced23548bf16A100 40GB 平衡点
quality35064fp32最高精度(需足够显存)

预设框架的工程价值在于降低决策成本:用户不需要理解每个参数的技术含义,只需选择一个预设。同时,每个预设参数都可以被命令行参数单独覆盖:

# 使用 balanced 预设但提高采样步数
python predict.py --preset balanced --sampling-steps 50

# 使用 low_mem 但切换到 bf16 省显存
python predict.py --preset low_mem --dtype bf16

# 禁用分块(需要足够显存)
python predict.py --preset quality --chunk-size -1

这种"预设 + 单参数覆盖"的设计模式在 ML 推理服务中非常实用——既提供了开箱即用的合理默认,又保留了精细调优的灵活性。

📦Vendor Pinning 与离线推理

ESMFold2 的依赖链有一个棘手的问题:Biohub 的 esm 包和 transformers 库都需要特定版本才能支持 esmfold2 模块,而这些版本在 PyPI 上可能尚未发布或与系统其他包冲突。

生产代码采用了vendor pinning策略——将依赖代码直接放入项目目录,通过 sys.path 注入而非 pip 安装:

sys.path.insert(0, os.path.join(BASE_DIR, "vendor/esm-c94ed8d"))
sys.path.insert(0, os.path.join(BASE_DIR, "vendor/transformers-main/src"))

from esm.models.esmfold2 import (
    ESMFold2InputBuilder, LigandInput, ProteinInput, StructurePredictionInput,
)
from transformers.models.esmfold2.modeling_esmfold2 import ESMFold2Model
Vendor 目录来源版本锁定
vendor/esm-c94ed8d/github.com/Biohub/esmcommit c94ed8d
vendor/transformers-main/HuggingFace transformers4.57.6 (Biohub fork)
vendor/biotite-1.6.0-*.whlBiotite1.6.0 (cp312)

这种方式的工程优势:

配合 Python 3.12 隔离虚拟环境(.venv_esmfold2_py312),整个预测层成为一个自包含的可移植单元。ESMFold2 要求 Python ≥3.12 且 <3.13,这个严格的版本要求通过专用 venv 解决,不干扰系统 Python 3.11。

🔄格式转换链:CIF → PDB → PDBQT

ESMFold2 输出 mmCIF 格式(现代 PDB 标准),但下游的对接和动力学工具链往往需要 PDB 或 PDBQT 格式。这构成了管线的第二层——格式转换链

CIF → PDB:BioPython 转换

mmCIF 到 PDB 的转换使用 BioPython 的 MMCIFParser

from Bio.PDB import PDBIO, MMCIFParser, Select

class LigandSelect(Select):
    """选择配体链(chain L)的原子。"""
    def accept_chain(self, chain):
        return chain.get_id() == "L"

def convert_cif_to_pdb(cif_path, pdb_path):
    parser = MMCIFParser(QUIET=True)  # QUIET 抑制格式警告
    structure = parser.get_structure("structure", cif_path)
    io = PDBIO()
    io.set_structure(structure)
    io.save(pdb_path)

QUIET=True 是一个重要的生产实践——mmCIF 文件经常包含 BioPython 解析器会警告的非标准字段,这些警告在批量处理时会淹没日志。配体提取通过自定义 Select 类实现,只保留 chain L 的原子。

手动 CIF 解析:处理边缘情况

BioPython 的解析器在某些边缘情况下会失败(如非标准残基编号、多模型 CIF)。生产代码还包含一个手动 CIF 解析器作为后备:

def cif_to_pdb_line(cif_line):
    """将 CIF ATOM 行手动转换为 PDB 格式。"""
    parts = cif_line.split()
    atom_id = parts[-1]
    atom_name = parts[12]    # auth_atom
    res_name = parts[9]      # auth_res
    chain = parts[11]        # auth_chain
    res_seq = parts[6]       # seq_id
    x, y, z = float(parts[14]), float(parts[15]), float(parts[16])
    element = parts[1]
    # PDB 固定列宽格式
    return f"ATOM  {atom_id:>5} {atom_name:<4} {res_name:<3} {chain:1} {res_seq:>4}    {x:>8.3f}{y:>8.3f}{z:>8.3f}  1.00  0.00          {element:>2}\n"

这个手动解析器直接按列索引提取 CIF 字段并格式化为 PDB 固定列宽格式。虽然不如 BioPython 健壮,但在 BioPython 失败时提供了降级路径

PDB → PDBQT:Meeko + RDKit

AutoDock Vina 要求 PDBQT 格式——在 PDB 基础上添加 AutoDock 特定的原子类型(AD4_TYPE)和可旋转键标记。生产代码使用 Meeko 库进行转换:

from meeko import MoleculePreparation, PDBQTWriterLegacy
from rdkit import Chem
from rdkit.Chem import AllChem

def prepare_pdbqt_meeko(input_pdb, output_pdbqt, molecule_type):
    # 受体:提取 chain A 避免多片段问题
    if molecule_type == 'receptor':
        temp_pdb = str(input_pdb).replace('.pdb', '_chainA.pdb')
        extract_chain_from_pdb(str(input_pdb), temp_pdb, 'A')
        input_pdb = temp_pdb

    mol = Chem.MolFromPDBFile(str(input_pdb), removeHs=False)
    mol = Chem.AddHs(mol)  # 添加显式氢

    # 配体:生成 3D 坐标
    if molecule_type == 'ligand':
        AllChem.EmbedMolecule(mol, randomSeed=42)
        AllChem.MMFFOptimizeMolecule(mol)

    # Meeko 准备:自动分配 AutoDock 原子类型和可旋转键
    preparator = MoleculePreparation()
    mol_setups = preparator.prepare(mol)
    pdbqt_string, _, _ = PDBQTWriterLegacy.write_string(mol_setups[0])

    with open(output_pdbqt, 'w') as f:
        f.write(pdbqt_string)

这里有一个关键的工程细节:受体只提取 chain A。ESMFold2 预测的复合物可能包含多条链(蛋白链 A、配体链 L),如果直接将整个 PDB 转为 PDBQT,Meeko 会因为多片段分子而失败。提取单链是一个简单但必要的预处理步骤。

🎯对接准备:搜索盒与结合评分

搜索盒自动计算

AutoDock Vina 需要用户指定一个搜索盒(中心坐标 + 尺寸),定义对接搜索的空间范围。生产代码从配体坐标自动计算搜索盒,避免人工指定:

def calculate_search_box(ligand_coords, padding=10.0):
    """从配体坐标计算搜索盒中心和尺寸。"""
    min_coords = ligand_coords.min(axis=0)
    max_coords = ligand_coords.max(axis=0)

    center = (min_coords + max_coords) / 2
    size = (max_coords - min_coords) + 2 * padding  # 10 Å 填充

    return center, size

10 Å 的填充(padding)是一个经验值——足够大以允许配体在对接中探索新的结合姿态,又足够小以保持搜索效率。生成的 Vina 配置文件:

receptor = /path/to/receptor.pdbqt
ligand = /path/to/ligand.pdbqt

center_x = -7.900
center_y = 26.137
center_z = 29.241

size_x = 26.480
size_y = 24.311
size_z = 21.468

exhaustiveness = 8    # 搜索彻底度
num_modes = 9         # 输出构象数
energy_range = 3.0    # 能量窗口 (kcal/mol)

距离几何结合评分

在正式对接之前,生产代码还实现了一个轻量级结合评分方法,基于原子间距离几何快速估计结合亲和力,无需运行完整的分子力学计算:

def analyze_interactions(protein_atoms, ligand_atoms):
    """基于距离的蛋白质-配体相互作用分析。"""
    interactions = {'hbonds': [], 'hydrophobic': [], 'van_der_waals': []}

    hbond_cutoff = 3.5    # 氢键截止距离 (Å)
    hydrophobic_cutoff = 4.5
    vdw_cutoff = 5.0

    for prot_atom in protein_atoms:
        for lig_atom in ligand_atoms:
            dist = calculate_distance(prot_atom, lig_atom)

            # 氢键:供体-受体对且距离 < 3.5 Å
            if dist < hbond_cutoff:
                if (is_hbond_donor(prot_atom) and is_hbond_acceptor(lig_atom)) or \
                   (is_hbond_donor(lig_atom) and is_hbond_acceptor(prot_atom)):
                    interactions['hbonds'].append({...})

            # 疏水接触:双方均为疏水残基且距离 < 4.5 Å
            if dist < hydrophobic_cutoff and is_hydrophobic(prot_atom) and is_hydrophobic(lig_atom):
                interactions['hydrophobic'].append({...})

            # 范德华接触:距离 < 5.0 Å
            if dist < vdw_cutoff:
                interactions['van_der_waals'].append({...})

    return interactions

def calculate_binding_score(interactions):
    """距离加权的结合亲和力评分。"""
    hbond_weight = -1.5      # kcal/mol per H-bond
    hydrophobic_weight = -0.5
    vdw_weight = -0.1

    score = 0.0
    for hbond in interactions['hbonds']:
        score += hbond_weight * (3.5 / hbond['distance'])  # 距离越短贡献越大
    for contact in interactions['hydrophobic']:
        score += hydrophobic_weight * (4.5 / contact['distance'])
    for contact in interactions['van_der_waals']:
        if contact['distance'] > 0:
            score += vdw_weight * (5.0 / contact['distance'])

    return score  # 负值表示有利结合

这个评分函数的工程价值在于速度——它只做 O(N×M) 的距离计算(N=蛋白原子数,M=配体原子数),可以在毫秒级完成。虽然精度远不如力场计算,但足以用于快速筛选:在批量预测场景下,先用距离评分过滤掉明显不结合的配体,再对候选配体运行耗时的 Vina 对接。

评分结果以 JSON 格式保存,便于程序化分析:

{
  "binding_score_kcal_mol": -49.11,
  "num_hbonds": 9,
  "num_hydrophobic": 0,
  "num_vdw_contacts": 220,
  "protein_atoms": 7230,
  "ligand_atoms": 10
}

🌊分子动力学:OpenMM 生产管线

管线的第三层是分子动力学(MD)模拟,使用 OpenMM 在 GPU 上运行。这是整个管线中最耗时的部分——一次 100 ns 的生产模拟在 A100 上需要数小时到数十小时。

力场组合

蛋白质-配体复合物的力场需要处理三类组分:蛋白、水和配体。生产代码使用了混合力场策略

组分力场来源
蛋白质Amber14amber14-all.xml
TIP3Pamber14/tip3p.xml
配体OpenFF 2.2.1SMIRNOFFTemplateGenerator
离子Amber14Na+/Cl-

配体力场通过 OpenFF 的 SMIRNOFF 模板生成器动态注册到 OpenMM 力场中:

from openff.toolkit.topology import Molecule
from openmmforcefields.generators import SMIRNOFFTemplateGenerator

# 从 RDKit 构建 OpenFF 分子
ligand_rdkit = Chem.MolFromPDBFile(str(ligand_pdb), removeHs=False, sanitize=True)
ligand_rdkit = Chem.AddHs(ligand_rdkit, addCoords=True)
ligand_off = Molecule.from_rdkit(ligand_rdkit, allow_undefined_stereo=True)

# 注册到力场
forcefield = ForceField("amber14-all.xml", "amber14/tip3p.xml")
ligand_generator = SMIRNOFFTemplateGenerator(
    molecules=[ligand_off],
    forcefield="openff-2.2.1.offxml"
)
forcefield.registerTemplateGenerator(ligand_generator.generator)

这种设计的巧妙之处在于力场统一——OpenFF 模板生成器将配体参数注入 Amber14 力场框架,OpenMM 在创建系统时自动处理蛋白-配体交叉项。用户不需要手动拼接力场文件。

系统准备流程

# 1. 拆分蛋白和配体
split_protein_and_ligand(input_pdb, protein_pdb, ligand_pdb, ligand_resname)

# 2. PDBFixer 修复蛋白结构
fixer = PDBFixer(filename=str(protein_pdb))
fixer.findMissingResidues()
fixer.missingResidues = {}        # 不添加缺失残基
fixer.findMissingAtoms()
fixer.addMissingAtoms()           # 添加缺失原子
fixer.addMissingHydrogens(pH=7.0) # 添加氢原子

# 3. 合并蛋白和配体
modeller = Modeller(fixer.topology, fixer.positions)
modeller.add(ligand_topology, ligand_positions)

# 4. 溶剂化
modeller.addSolvent(
    forcefield,
    model="tip3p",
    padding=1.0 * unit.nanometer,       # 1 nm 水盒子边界
    ionicStrength=0.15 * unit.molar,    # 生理盐浓度
    positiveIon="Na+",
    negativeIon="Cl-",
)

PDBFixer 是一个关键的生产工具——AI 预测的蛋白质结构经常缺少氢原子或有非标准残基命名,PDBFixer 自动修复这些问题,确保力场参数化不会失败。

平衡协议

生产模拟采用三阶段平衡协议,逐步将系统从初始状态引导到生产条件:

阶段系综时长关键操作
能量最小化8000 步消除立体冲突
NVT 暖机NVT200 ps50K→300K 温度爬升
NPT 平衡NPT800 ps1 atm 压力平衡
生产模拟NPT100 ns数据采集

NVT 暖机阶段有一个精巧的设计——从 50K 开始而不是直接 300K。如果直接将系统加热到 300K,初始结构中的立体冲突会产生巨大的力,导致系统崩溃。从 50K 缓慢升温让系统先松弛最严重的冲突,再逐步达到目标温度。

平台配置

platform = Platform.getPlatformByName("CUDA")
properties = {
    "DeviceIndex": gpu_id,
    "Precision": "mixed",            # mixed > single(稳定性)
    "UseCpuPme": "false",            # PME 在 GPU 上执行
    "DeterministicForces": "true",   # 确定性力计算(可复现)
}

"Precision": "mixed" 是生产 MD 的推荐设置——键力用 fp32 精度计算(避免能量漂移),非键力用 fp16 计算(加速 2-3 倍)。"DeterministicForces": "true" 确保相同输入产生相同输出,这对可复现性至关重要。

🔒配体约束:解决"配体飞走"问题

从 AI 预测的蛋白质-配体复合物出发运行 MD 模拟,有一个普遍的工程问题:配体在纳秒时间尺度内就扩散到水相中,脱离结合位点。这不是代码 bug,而是物理现实——AI 预测的结合姿态可能不够深埋或不够预组织,小分子在热运动下很容易逃逸。

在没有约束的情况下,100 ns 的模拟中配体可能只在最初 1 ns 内与蛋白有接触,之后 99 ns 都在溶剂中自由扩散。这样的轨迹无法用于结合动力学分析——你得到的只是"配体在水中扩散"的数据,而不是"配体-蛋白结合"的数据。

配体特异性谐和约束

生产代码实现的解决方案是配体特异性谐和约束——在模拟初期对配体重原子施加谐和势,将配体维持在初始位置附近,让系统先松弛蛋白侧链和水分子,然后再释放约束:

def add_ligand_restraints(system, topology, positions, k, ligand_resname="LIG"):
    """对配体重原子施加谐和约束。
    k: 力常数 (kJ/mol/nm²)
    返回被约束的原子数。
    """
    if k <= 0:
        return 0

    force = CustomExternalForce("0.5*k*((x-x0)^2 + (y-y0)^2 + (z-z0)^2)")
    force.addGlobalParameter("k", k * unit.kilojoule_per_mole / unit.nanometer**2)
    force.addPerParticleParameter("x0")
    force.addPerParticleParameter("y0")
    force.addPerParticleParameter("z0")

    n = 0
    for idx, atom in enumerate(topology.atoms()):
        # 只约束配体重原子,不约束氢原子
        if atom.residue.name == ligand_resname and atom.element.symbol != "H":
            x, y, z = positions[idx].value_in_unit(unit.nanometer)
            force.addParticle(idx, [x, y, z])
            n += 1

    if n > 0:
        system.addForce(force)
    return n

这个设计的三个关键决策:

分阶段释放策略

推荐的两阶段生产策略

# 阶段一:轻约束生产(让配体不逃逸,但允许局部松弛)
python run_openmm_md_production.py \
  --production-ns 50 \
  --ligand-restraint-k 50 \
  --ligand-restraint-duration-ns 10 \
  --out-dir .../md/production_run_v2

# 阶段二:释放约束继续生产(验证约束下松弛的姿势是否稳定)
python run_openmm_md_production.py \
  --resume \
  --ligand-restraint-k 0 \
  --production-ns 100 \
  --out-dir .../md/production_run_v2

力常数 k=50 kJ/mol/nm² 是一个经验推荐值——足够强以防止配体在 ns 时间尺度上扩散,又足够弱以允许配体在结合位点内做局部运动。这个值可以通过 --ligand-restraint-k 参数调整:

k (kJ/mol/nm²)效果适用场景
0无约束(自由扩散)验证约束下姿势的稳定性
20-50轻约束(推荐)初始生产,姿势不确定时
50-100中约束配体很小、容易逃逸时
100+强约束仅用于极不稳定的初始姿势

♻️检查点与恢复:长时模拟容错

100 ns 的 MD 模拟在 A100 上需要 10+ 小时。在这段时间内,任何中断(GPU 驱动重置、电源故障、手动终止)都会导致模拟进度丢失——除非有检查点恢复机制

生产代码实现了一套完整的检查点架构,由四类文件组成:

文件格式内容更新频率
equilibrated.pdbPDB平衡后结构平衡结束时
equilibrated_state.xmlXMLOpenMM State(位置+速度+盒子)平衡结束时
production.chk二进制OpenMM 检查点(完整上下文)每 50 ps
final_state.jsonJSON元数据(参数、进度、约束配置)模拟结束时
production.dcdDCD轨迹文件每 50 ps(追加模式)

恢复逻辑

# 检查恢复条件
has_resume_files = (
    args.resume
    and checkpoint_file.exists()
    and eq_state_file.exists()
    and (out_dir / "equilibrated.pdb").exists()
)

if has_resume_files:
    # 从平衡后状态重建模型(跳过整个平衡流程)
    forcefield, modeller, _, _ = prepare_resume_model(args, out_dir)
    platform, properties = make_platform(args)

    # 构建生产系统(包括重新添加约束)
    system_prod = create_base_system(forcefield, modeller.topology)
    system_prod.addForce(MonteCarloBarostat(pressure, temperature, 25))

    if args.ligand_restraint_k > 0:
        eq_state = XmlSerializer.deserialize(eq_state_file.read_text())
        add_ligand_restraints(system_prod, modeller.topology,
                              eq_state.getPositions(),
                              args.ligand_restraint_k)

    integrator_prod = LangevinMiddleIntegrator(temperature, friction, timestep)
    sim_prod = Simulation(modeller.topology, system_prod, integrator_prod, platform, properties)

    # 从检查点恢复(关键步骤)
    try:
        sim_prod.loadCheckpoint(str(checkpoint_file))
        resumed_from_checkpoint = True
    except Exception as exc:
        print(f"Checkpoint load failed ({exc}), falling back to equilibrated state.")
        eq_state = XmlSerializer.deserialize(eq_state_file.read_text())
        sim_prod.context.setPositions(eq_state.getPositions())
        sim_prod.context.setVelocities(eq_state.getVelocities())

恢复逻辑的工程亮点:

元数据审计

每次模拟结束时,所有运行参数和状态都保存到 final_state.json

payload = {
    "input_pdb": str(args.input_pdb),
    "temperature_k": args.temperature_k,
    "pressure_atm": args.pressure_atm,
    "timestep_fs": args.timestep_fs,
    "production_ns": args.production_ns,
    "production_steps_total": production_steps_total,
    "production_steps_completed": int(sim_prod.currentStep),
    "ligand_restraint_k": args.ligand_restraint_k,
    "ligand_restraint_duration_ns": args.ligand_restraint_duration_ns,
    "n_lig_restrained": n_lig_restrained,
    "resumed_from_checkpoint": resumed_from_checkpoint,
    "elapsed_seconds": elapsed,
}
save_metadata(out_dir / "final_state.json", payload)

这个 JSON 文件是模拟审计的基础——通过它可以在事后精确知道每次模拟使用了什么参数、运行了多少步、是否从检查点恢复、约束了多少原子。这对于可复现性和结果溯源至关重要。

💡工程启示与可迁移模式

拆解这条三层管线后,可以提炼出一组可迁移的工程模式——它们不依赖于 ESMFold2 或特定生物学靶点,适用于任何"AI 预测 + 物理模拟"的计算管线:

模式核心思想适用场景
GPU 预检加载前检查显存,不满足则立即失败所有大模型推理
OOM 自动降级捕获 OOM,用保守参数重试显存余量紧张的推理
CPU 亲和性隔离预留核心给系统,BLAS 线程数 + sched_setaffinity共享 GPU 服务器
参数预设框架预设 + 单参数覆盖,降低决策成本多参数推理服务
本地资源缓存CCD/模型/依赖本地化,消除网络依赖离线/批量推理
Vendor Pinningsys.path 注入替代 pip install版本敏感的依赖
文件系统解耦层间通过文件通信,可独立调试多阶段计算管线
配体约束策略初期约束 + 后期释放,解决初始姿势不稳定从预测结构出发的 MD
检查点恢复定期检查点 + 平衡状态保存 + 追加日志长时计算任务
元数据审计每次运行保存完整参数和状态 JSON可复现性要求

架构层面的启示

这条管线最本质的架构启示是"AI 预测 → 物理模拟"的桥接工程。AI 预测(ESMFold2)提供初始结构假设,物理模拟(OpenMM MD)验证和精化这个假设。两者之间的"桥"是格式转换链和约束策略——这座桥的质量决定了整条管线的有效性。

具体来说,AI 预测的结构有三个特征会影响下游 MD:(1) 可能缺少氢原子(需要 PDBFixer);(2) 配体结合姿态可能不够深埋(需要配体约束);(3) 侧链构象可能不是能量最优(需要平衡松弛)。这三个问题分别由管线中的三个工程组件解决,缺一不可。

运维层面的启示

从运维角度看,这条管线展示了"单卡生产"的工程范式——不需要分布式集群,一张 A100 40GB 就可以运行完整的"预测→对接→MD"管线。关键在于资源治理的精细程度:GPU 预检确保不 OOM、CPU 亲和性确保不影响其他服务、检查点确保中断可恢复。这些治理措施让单卡服务器能够可靠地运行多用户、多任务的计算管线。

可复现性层面的启示

可复现性不是单一措施,而是一组工程实践的组合:

这些实践共同构成了一个可审计、可复现的计算管线——任何人拿到项目目录和参数,可以在不同服务器上重现相同的结果。这在科学计算领域是黄金标准。