在 Hacker News 上看到 Sous.bio 的展示时最先让人记住的是它的定位your sous chef in the lab。副主厨在厨房里负责备料、配菜、把控火候、整理出餐顺序实验室同样需要这样一位角色把实验方案整理成可执行清单把试剂浓度换算成实际体积把操作结果写成可追溯记录。Sous.bio 做的是这一组能力下面我用一个最小原型演示这类工具应该怎么搭。这里先说明边界下文不是 Sous.bio 的源码复刻也不代表它的官方实现。它更像一个工程练习用来解释“实验室副主厨”这类工具从数据模型、核心计算到命令行验证的完整思路。读者不需要有生物实验背景但最好对 Python 和命令行有基本了解。文章会带着你写完一个可运行的实验室流程助手它能把 YAML 写成的实验配方变成可执行的步骤清单并且把操作记录持久化到 SQLite。1. Sous.bio 想解决的问题实验室流程为什么需要“副主厨”1.1 实验室和厨房的工作流高度相似一份写得不清楚的菜谱会让厨师手忙脚乱盐是多少克、腌制多久、烤箱上下火多少度任何一个模糊点都会影响成品。实验方案也是这样而且实验对误差的容忍度更低。PCR 反应体系里每种试剂加多少微升母液浓度是多少先加哪种试剂是否在冰上操作都会直接影响实验能否重复。如果把厨房里的角色映射到实验室对应关系非常自然厨房概念实验室概念说明菜谱Protocol / SOP定义实验步骤、试剂、条件食材试剂、酶、缓冲液需要明确种类、浓度、用量份量体积、质量、浓度换算错误会导致实验失败火候温度、时间、转速条件参数需要被记录和校验试菜预实验先做小规模验证再放大出餐顺序步骤依赖顺序前一步出错会传导到后一步这个类比不是修辞。它说明实验室工作流之所以复杂是因为流程节点多、参数分散、人为计算容易出错。Sous.bio 定位中的“sous chef”本质是给科研人员配一个能处理流程细节的辅助系统而不是替代做实验的人。1.2 实验室助手应该承担哪些功能从工程角度看实验室助手可以拆成几个明确功能域功能域要解决的问题典型操作配方管理实验方案分散在 Excel、纸质本和聊天记录里创建、版本化、审核、复用 Protocol试剂计算配液时频繁做浓度和体积换算稀释计算、摩尔浓度换算、母液取样流程校验步骤缺失、顺序错误、参数不全校验步骤编号、试剂用量、依赖条件实验记录做完实验后结果难追溯记录操作人、时间、方案快照、结果 JSON提醒与追溯用错试剂、漏写条件操作清单、日志、审计记录这些功能不一定一次性做完。对实验室工具来说最大的风险是“看起来很智能实际上不可靠”。所以技术切入点应该是先做确定性逻辑配方能解析、计算能验证、记录能落地然后再考虑推荐、搜索、可视化等增强能力。1.3 先圈定一个最小核心基于这个判断原型只做四件事用 YAML 文件定义实验配方。用命令读取、校验、展示配方。用公式完成浓度稀释计算。用 SQLite 保存每次实验的记录。这个核心看起来简单但它覆盖了实验室工具的完整闭环输入、计算、输出、持久化。后续所有扩展都可以挂在上面。2. 先定数据模型配方、步骤和记录如何组织2.1 配方文件用 YAML 而不是 Excel实验方案最怕格式不统一。Excel 很好用但很难做版本管理也很难在代码里校验。YAML 是更适合作为结构化的配方文件格式纯文本、可写注释、容易进 Git、解析成本低。下面是一个 PCR 体系配方的 YAML 示例。它定义了配方编号、名称、版本以及三个步骤。每个步骤可以有试剂清单和条件参数。id: pcr_mix name: PCR Mix 配置 version: 1.0 steps: - order: 1 title: 配制 50 µL PCR 反应体系 action: 在冰上按顺序加入下列试剂 reagents: - name: ddH2O amount: 38.5 µL - name: 10x Taq Buffer amount: 5 µL - name: dNTP Mix (2.5 mM) amount: 4 µL - name: Forward Primer (10 uM) amount: 1 µL - name: Reverse Primer (10 uM) amount: 1 µL - name: Taq DNA Polymerase amount: 0.5 µL condition: temperature: 4°C - order: 2 title: 混匀 action: 用移液器轻轻吹打混匀避免产生气泡 - order: 3 title: 分装 action: 按每管 25 µL 分装到 PCR 管中这个文件有几个设计要点id是配方唯一标识用于命令引用。version用来区分方案迭代避免“最终版/最终版2/真正最终版”的问题。amount统一写成带单位的字符串而不是单独的数值和单位字段。原因是人写起来方便但校验层必须解析成标准单位。condition是一个字典可以存放温度、时间、转速等条件。如果配方文件里的amount被写成“补齐至 50 µL”解析层会立刻报错。这逼着配方作者把参数写明确对实验可复现反而更安全。2.2 SQLite 表结构设计为了保存配方元数据和实验记录使用 SQLite 就足够不需要单独启动数据库服务。设计两张表PRAGMA journal_mode WAL; CREATE TABLE IF NOT EXISTS protocols ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, file_path TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS experimental_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, protocol_id TEXT NOT NULL, operator TEXT NOT NULL, started_at TEXT NOT NULL DEFAULT (datetime(now)), raw_protocol TEXT NOT NULL, result_json TEXT, FOREIGN KEY (protocol_id) REFERENCES protocols(id) );字段含义如下表字段类型说明protocolsidTEXT PRIMARY KEY配方标识与 YAML 中 id 对应protocolsnameTEXT配方名称protocolsversionTEXT配方版本号protocolsfile_pathTEXTYAML 文件路径protocolscreated_atTEXT创建时间experimental_recordsidINTEGER PRIMARY KEY记录 IDexperimental_recordsprotocol_idTEXT关联的配方experimental_recordsoperatorTEXT操作人experimental_recordsstarted_atTEXT实验开始时间experimental_recordsraw_protocolTEXT配方 JSON 快照experimental_recordsresult_jsonTEXT实验结果 JSONraw_protocol存配方快照很关键。它的作用是即使以后配方 YAML 文件被修改历史实验记录仍保留当时执行的完整方案。这比只存一个protocol_id更可靠因为版本变更后只看外键无法还原当时的操作。2.3 技术选型和项目目录结构原型使用 Python 3.10、标准库sqlite3和 PyYAML命令行通过argparse实现不引入 Web 框架。这样依赖最少便于读者理解核心逻辑。项目目录结构sous-lab/ ├── requirements.txt ├── schema.sql ├── sous_core.py ├── cli.py ├── protocols/ │ └── pcr_mix.yaml └── sous.dbrequirements.txt只需要一行PyYAML6.0为什么不一开始就用 FastAPI 做 Web 界面因为实验室工具的核心是流程正确性和数据可靠性不是界面。先用命令行把计算、校验、持久化跑通后面再加 API 层也不会推翻现有设计。如果需要多人协作再引入服务化和权限体系。3. 实现核心引擎YAML 配方解析与稀释计算3.1 用数据类承载配方结构在sous_core.py中定义三个数据类Reagent、Step、Protocol。数据类的职责是让配方结构在代码中可见而不是用一堆字典来回传递。 sous_core.py - 实验室副主厨原型核心逻辑 from __future__ import annotations import json import re import sqlite3 from dataclasses import asdict, dataclass, field from pathlib import Path import yaml # 单位转换表所有转换后的单位统一为 mM 和 µL CONC_TO_MM { m: 1000, mm: 1, um: 1e-3, nm: 1e-6, } VOL_TO_UL { l: 1_000_000, ml: 1000, ul: 1, nl: 1e-3, } dataclass class Reagent: name: str amount: str dataclass class Step: order: int title: str action: str reagents: list[Reagent] field(default_factorylist) condition: dict field(default_factorydict) dataclass class Protocol: id: str name: str version: str steps: list[Step] field(default_factorylist)关键点是amount保持为原始字符串。这样做的好处是 YAML 里写什么代码里就能看到什么只在需要计算时再解析成数值。如果一开始就把amount转成浮点数单位信息会丢失后续无法做单位换算。3.2 体积解析和单位换算实验室常见的体积字符串有1 mL、25 µL、100 uL、500 nL。为了统一计算先把它们全部转成µL。def to_volume_ul(amount: str) - float: 把 25 µL、1 mL、100 uL 这类字符串转成 µL。 text amount.strip().lower() # 兼容 µl 和 ul text text.replace(µl, ul) match re.fullmatch(r([0-9.])\s*(ul|ml|l|nl), text) if not match: raise ValueError(f无法解析体积字符串: {amount}) value float(match.group(1)) unit match.group(2) return value * VOL_TO_UL[unit]这里用一个正则完成解析和校验如果用户写了38.5µL或38.5 µL都能解析。如果写了约 1 mL、补齐至 50 µL正则匹配失败函数抛出ValueError避免带病数据进入后续流程。浓度换算统一到mM。M和mol/L等价1 M 1000 mM1 µM 0.001 mM。换算函数如下def calc_volume(stock_conc: float, stock_unit: str, target_conc: float, target_unit: str, target_vol: float, target_vol_unit: str) - dict: 按 C1V1C2V2 计算母液取样量和溶剂补足量。 try: stock_mm stock_conc * CONC_TO_MM[stock_unit.lower()] target_mm target_conc * CONC_TO_MM[target_unit.lower()] target_vol_ul target_vol * VOL_TO_UL[target_vol_unit.lower()] except KeyError as exc: raise ValueError(f不支持的单位: {exc}) from exc if stock_mm 0: raise ValueError(母液浓度必须大于 0) if target_mm stock_mm: raise ValueError(目标浓度必须小于母液浓度) stock_vol_ul target_mm * target_vol_ul / stock_mm solvent_vol_ul target_vol_ul - stock_vol_ul return { stock_volume_ul: round(stock_vol_ul, 2), stock_volume_display: f{stock_vol_ul:.2f} µL, solvent_volume_display: f{solvent_vol_ul:.2f} µL, }这里有一个工程取舍统一转成mM和µL后公式就变成简单的乘除法。缺点是必须维护两张转换表但换来的是计算代码的清晰和可测试。如果实验室有更多单位比如g/L、mg/mL只需要扩展转换表和分子量库公式本身不必重写。3.3 协议加载和校验load_protocol负责把 YAML 文件加载成Protocol对象def load_protocol(path: Path) - Protocol: raw yaml.safe_load(path.read_text(encodingutf-8)) if not isinstance(raw, dict): raise ValueError(f{path} 不是合法的字典格式) steps [] for item in raw.get(steps, []): reagents [] for r in item.get(reagents, []): name r.get(name, ).strip() amount r.get(amount, ).strip() if amount: to_volume_ul(amount) reagents.append(Reagent(namename, amountamount)) steps.append( Step( orderint(item[order]), titleitem[title].strip(), actionitem[action].strip(), reagentsreagents, conditionitem.get(condition, {}), ) ) return Protocol( idraw[id].strip(), nameraw[name].strip(), versionstr(raw[version]).strip(), stepssteps, )加载时立刻调用to_volume_ul是为了让格式错误尽早暴露而不是等到实验记录阶段才报错。校验函数则负责给出更完整的错误列表def validate_protocol(protocol: Protocol) - list[str]: errors [] orders [step.order for step in protocol.steps] if len(orders) ! len(set(orders)): errors.append(存在重复的步骤 order) for step in protocol.steps: if step.order 0: errors.append(f步骤 {step.order} 的 order 必须为正整数) if not step.title or not step.action: errors.append(f步骤 {step.order} 缺少 title 或 action) for reagent in step.reagents: if not reagent.name: errors.append(f步骤 {step.order} 中存在空试剂名称) try: to_volume_ul(reagent.amount) except ValueError as exc: errors.append(f步骤 {step.order} 试剂 {reagent.name}: {exc}) return errors校验逻辑看起来很基础但它能挡住最主要的问题步骤编号重复、步骤内容缺失、试剂用量格式错误。一个配方必须先通过校验才能进入实验记录流程。3.4 生成操作清单并保存记录generate_checklist把结构化配方转成可阅读的文本清单适合打印或直接看屏幕def generate_checklist(protocol: Protocol) - list[str]: lines [fProtocol: {protocol.name} (v{protocol.version})] for step in protocol.steps: lines.append(f[{step.order}] {step.title}: {step.action}) for reagent in step.reagents: lines.append(f - {reagent.name}: {reagent.amount}) for key, value in step.condition.items(): lines.append(f 条件 {key}: {value}) return lines保存记录时把完整Protocol对象序列化为 JSON 快照再写入experimental_records表def save_record(db_path: Path, protocol: Protocol, operator: str, result_json: dict) - int: conn sqlite3.connect(db_path) cur conn.execute( INSERT INTO experimental_records (protocol_id, operator, raw_protocol, result_json) VALUES (?, ?, ?, ?) , ( protocol.id, operator, json.dumps(asdict(protocol), ensure_asciiFalse, defaultstr), json.dumps(result_json, ensure_asciiFalse, defaultstr), ), ) conn.commit() record_id cur.lastrowid conn.close() return record_idasdict(protocol)会把嵌套的Step和Reagent也转成字典序列化后就能还原当时的完整配方。这里要留意amount仍然是字符串所以即使以后换了单位解析规则历史记录里的原始写法依然保留。4. 跑通最小闭环命令行工具安装、初始化和验证4.1 环境准备先创建项目目录和虚拟环境然后把上一节的sous_core.py、schema.sql、protocols/pcr_mix.yaml放进去再创建一个cli.py。虚拟环境隔离依赖避免污染系统 Python。mkdir sous-lab cd sous-lab python -m venv .venv source .venv/bin/activate pip install -r requirements.txt4.2 命令行入口cli.py使用argparse暴露六个子命令init-db、list、show、validate、calc、run。下面是完整代码import argparse import sys from pathlib import Path from sous_core import ( calc_volume, find_protocol, generate_checklist, init_db, load_protocol, save_record, validate_protocol, ) BASE_DIR Path(__file__).resolve().parent DB_PATH BASE_DIR / sous.db SCHEMA_PATH BASE_DIR / schema