☰
Jupyter 笔记本新鲜度检查:用 freeze-check 守住 _quarto.yml 与 ipynb 的同步
2026/9/26 10:35:19 网站建设 项目流程

1. 当 Quarto 渲染出来的图还是上周的

你有没有遇到过这种情况:明明昨天刚在 Jupyter 里改完数据清洗逻辑,重新跑了一遍 notebook,结果quarto render出来的 HTML 里,图表还是上周那版。翻回.ipynb一看,输出确实更新了,但_quarto.yml里注册的 notebook 路径和实际执行状态对不上,Quarto 直接用了_freeze/里的旧缓存。

这不是 Quarto 的 bug,而是「笔记本新鲜度」这件事本身没有自动守门人。_quarto.yml只负责声明哪些 notebook 参与渲染,它不关心你的.ipynb最后一次执行是什么时候、输出是不是比源码旧、_freeze缓存是不是已经过期。于是就有了一个很典型的脱节场景:源码改了、输出没重跑、缓存还在,渲染结果看起来正常,实际是旧数据。

freeze-check就是用来堵这个口子的。它读取_quarto.yml里manuscript.notebooks注册的路径,逐个检查.ipynb是否有输出、最后修改时间、输出时效性、以及_freeze/notebooks/<name>/缓存状态,最后给出一张新鲜度汇总表。适合用 Jupyter 做分析、用 Quarto 发布报告或论文的开发者,尤其是那种「渲染前心里没底、不知道哪个 notebook 该重跑」的日常流程。

下面我会先讲清楚它检查的四个维度,再给一份可复制的配置骨架和检查命令,然后完整演示一次「改 ipynb → 发现 stale → 重执行 → 验证同步」的动作。全程不需要你手动比对时间戳。

2. 把 freeze-check 接进 Quarto 项目

2.1 它到底检查什么

freeze-check的核心不是简单比文件修改时间,而是四个维度一起看:

维度检查内容判定意义
Has outputs打开.ipynbJSON,看 code cell 的outputs数组是否非空没输出说明根本没执行过
Last modified.ipynb文件本身的修改时间戳源码是否被改过
Outputs agecell metadata 里的执行时间戳(如果有)输出是什么时候生成的
Freeze cache_freeze/notebooks/<name>/是否存在且含缓存Quarto 会不会直接用缓存

四个维度组合出四种状态:Current(有输出且源码没比输出新)、Stale(有输出但源码改得比输出晚)、Unexecuted(任何 code cell 都没输出)、Freeze only(没 cell 输出但有_freeze缓存,Quarto 会走缓存)。

关键点在于Stale和Freeze only这两种最容易被忽略。前者渲染出来是旧结果,后者渲染出来可能连执行都跳过。

2.2 前置准备:项目结构与依赖

假设你的 Quarto 项目长这样:

my-report/ ├── _quarto.yml ├── _freeze/ │ └── notebooks/ │ ├── notebook-01/ │ └── notebook-02/ ├── notebooks/ │ ├── notebook-01.ipynb │ └── notebook-02.ipynb └── index.qmd

_quarto.yml里注册 notebook 的部分通常是这样:

project: type: book manuscript: notebooks: - notebooks/notebook-01.ipynb - notebooks/notebook-02.ipynb

freeze-check只认manuscript.notebooks这个列表,路径写错或漏注册,它就会报No notebooks found in _quarto.yml或者把文件标成 missing。所以第一步永远是确认这个列表和磁盘上的.ipynb一一对应。

如果你还没配好模型侧的调用环境,可以先把 API Key 准备好,后面做自动化检查脚本时会用到。入口在 API Keys,接入方式看 接入文档。这一步不是必须的,纯本地检查也能跑,但如果你想把检查结果自动汇总成报告,有个稳定的模型接口会省事很多。

3. 可复制的配置骨架与检查命令

3.1 配置骨架

freeze-check本身是一个检查技能,落地到项目里我建议放一个scripts/freeze_check.py,把读取_quarto.yml、遍历 notebook、判定状态这三步写死。骨架如下:

import json import os import time from pathlib import Path import yaml QUARTO_YML = Path("_quarto.yml") FREEZE_DIR = Path("_freeze/notebooks") def load_notebooks(): with open(QUARTO_YML, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) return cfg.get("manuscript", {}).get("notebooks", []) def has_outputs(nb_path: Path) -> bool: with open(nb_path, "r", encoding="utf-8") as f: nb = json.load(f) for cell in nb.get("cells", []): if cell.get("cell_type") == "code" and cell.get("outputs"): return True return False def outputs_age(nb_path: Path): with open(nb_path, "r", encoding="utf-8") as f: nb = json.load(f) stamps = [] for cell in nb.get("cells", []): meta = cell.get("metadata", {}) ts = meta.get("execution", {}).get("iopub.execute_input") if ts: stamps.append(ts) return max(stamps) if stamps else None def freeze_exists(nb_path: Path) -> bool: name = nb_path.stem cache = FREEZE_DIR / name return cache.exists() and any(cache.iterdir()) def check_one(nb_path: Path): if not nb_path.exists(): return "Missing", None mtime = nb_path.stat().st_mtime has_out = has_outputs(nb_path) age = outputs_age(nb_path) frozen = freeze_exists(nb_path) if not has_out and frozen: return "Freeze only", mtime if not has_out: return "Unexecuted", mtime if age and mtime > age: return "Stale", mtime return "Current", mtime def main(): notebooks = load_notebooks() if not notebooks: print("No notebooks found in _quarto.yml") return print(f"{'Notebook':<28}{'Has Outputs':<14}{'Last Modified':<22}{'Status'}") print("-" * 78) for rel in notebooks: nb = Path(rel) status, mtime = check_one(nb) has_out = "Yes" if nb.exists() and has_outputs(nb) else "No" ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime)) if mtime else "-" print(f"{nb.name:<28}{has_out:<14}{ts:<22}{status}") if __name__ == "__main__": main()

这段代码就是freeze-check的本地实现,逻辑和前面表格里的四维度完全对应。outputs_age读的是 cell metadata 里的执行时间戳,不是所有内核都会写,所以判定Stale时如果拿不到 age,会退化成只看mtime和是否有输出。

3.2 运行检查

在项目根目录执行:

python scripts/freeze_check.py

输出会是一张表:

Notebook Has Outputs Last Modified Status ------------------------------------------------------------------------------ notebook-01.ipynb Yes 2026-02-28 14:30 Current notebook-02.ipynb Yes 2026-03-01 09:15 Stale notebook-03.ipynb No 2026-02-25 11:00 Unexecuted

如果出现Stale或Unexecuted,脚本会提示你重执行。Quarto 项目里对应的动作是:

quarto render --execute

或者只重跑 notebook:

jupyter nbconvert --to notebook --execute notebooks/notebook-02.ipynb \ --output notebooks/notebook-02.ipynb

注意--execute会重新生成输出并更新 cell metadata 里的时间戳,这样下一次freeze-check才会把它判成Current。

4. 一次完整的同步验证

4.1 改一个 notebook

打开notebooks/notebook-02.ipynb,随便改一个 cell,比如把df.groupby("region").sum()改成df.groupby("region").mean(),保存。此时.ipynb的mtime更新了,但 cell 输出还是旧的。

跑一次检查:

python scripts/freeze_check.py

你会看到notebook-02.ipynb的状态从Current变成Stale,因为mtime > outputs_age。这就是脱节的信号:源码新、输出旧。

4.2 重执行并验证

执行:

jupyter nbconvert --to notebook --execute notebooks/notebook-02.ipynb \ --output notebooks/notebook-02.ipynb

再跑一次freeze_check.py:

notebook-02.ipynb Yes 2026-03-01 09:40 Current

状态回到Current,说明输出已经跟上源码。这时候再quarto render,渲染结果就是新的。

如果你在检查过程中想确认某个 notebook 的执行逻辑对不对,可以把关键 cell 贴到 模型对话 里让模型帮你核对,尤其是涉及数据聚合、时间窗口这类容易写错的地方。

4.3 把检查接进日常流程

最省事的做法是在quarto render之前挂一个 pre-render 钩子。Quarto 支持_quarto.yml里配project: pre-render:

project: type: book pre-render: python scripts/freeze_check.py

这样每次渲染前都会先跑一遍新鲜度检查,有Stale或Unexecuted就直接暴露出来,不会等到 HTML 出来才发现图是旧的。

如果你在做长期编码或 Agent 类的自动化流程,可以把检查脚本和重执行命令串成一个任务,交给 Coding Plan 里的工作流去跑,省得每次手动敲。

5. 本篇常见错排查

5.1 报 No notebooks found in _quarto.yml

说明manuscript.notebooks是空的或者键名写错了。检查_quarto.yml里是不是写成了notebooks:而不是manuscript: notebooks:。Quarto 的 book 项目里 notebook 注册必须在manuscript下,写成顶层notebooks不会被识别。

5.2 某个 notebook 被标成 Missing

freeze-check按_quarto.yml里的相对路径去找文件,路径是相对项目根目录的。如果你在子目录里跑脚本,Path(rel)就会解析错。解决办法是脚本里统一用项目根目录做基准:

ROOT = Path(__file__).resolve().parent.parent nb = ROOT / rel

5.3 状态一直是 Stale,重执行也没用

大概率是 cell metadata 里没有执行时间戳,outputs_age返回None,判定逻辑退化成只看mtime。有些内核(比如部分老版本 ipykernel)不写iopub.execute_input。这时候可以改用nbconvert的--ExecutePreprocessor.record_timing=True强制记录:

jupyter nbconvert --to notebook --execute notebooks/notebook-02.ipynb \ --output notebooks/notebook-02.ipynb \ --ExecutePreprocessor.record_timing=True

5.4 Freeze only 状态怎么处理

Freeze only表示没有 cell 输出但有_freeze缓存,Quarto 渲染时会直接用缓存。如果你希望渲染时真正执行 notebook,需要先清掉缓存再重执行:

rm -rf _freeze/notebooks/notebook-03 jupyter nbconvert --to notebook --execute notebooks/notebook-03.ipynb \ --output notebooks/notebook-03.ipynb

清缓存这一步要谨慎,确认缓存不是唯一输出来源再删。

5.5 检查脚本报 YAML 解析错

_quarto.yml里如果有 tab 缩进或者中文冒号,yaml.safe_load会直接抛异常。Quarto 的 YAML 必须用空格缩进,冒号用英文。可以用python -c "import yaml; yaml.safe_load(open('_quarto.yml'))"单独验证一下配置文件本身能不能解析。

6. 把新鲜度检查变成渲染前的默认动作

freeze-check的价值不在于它多复杂,而在于它把「哪个 notebook 该重跑」这件事从人脑记忆变成了可执行的检查。四维度判定里,Stale和Freeze only是最容易骗过眼睛的两种状态,前者渲染出旧结果,后者直接跳过执行。把检查脚本挂到pre-render,每次渲染前自动跑一遍,基本就能告别「图是上周的」这类问题。

如果你想把检查结果自动汇总、或者在多项目之间统一管理,可以走 控制台 配一套 API 调用,把freeze_check.py的输出结构化后发给模型做摘要。接入细节在 接入文档 里有完整说明,Key 在 API Keys 页面生成。纯本地检查不需要这些,但一旦你想把新鲜度检查纳入 CI 或者多人协作流程,有个稳定的接口会方便很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询