<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Python · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/python/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/python/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest 加速：用 pytest-testmon 只跑受代码改动影响的测试</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmon 只跑改动能触及的测试，依据是覆盖率记录而不是猜测。把它加进 2,248 个测试的 Django 套件、它需要的包装脚本，以及 Python 3.14 的一个坑。</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a> 是一个 pytest 插件，只跑你的改动可能影响到的测试。它借助 coverage.py 观察每个测试执行了哪些代码行，并为测试碰过的每个代码块存一个校验和。下次运行时，它把这些校验和与磁盘上的代码对比，只跑代码块有变化的测试，再加上上次失败的测试，所以它顺带也干了 <code>pytest --lf</code> 的活。</p>
<p>这类技术统称测试影响分析（test impact analysis），而 testmon 是在 Python 里免费、开源地用上它的一种办法。它完全不依赖 git：它对比的是上次运行时看到的代码，提交没提交都一样。</p>
<p>这周我把它加进了我那个集市应用的 Python 测试里，原因并不是为了快而快。</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="讲述这篇文章的一段短片">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文" default>
  </video><figcaption>这篇文章的约八分钟视频版。旁白是英文，配有中文字幕。</figcaption>
</figure>


<h2 class="relative group">为什么要加它
    <div id="为什么要加它" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e5%8a%a0%e5%ae%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>一个功能需求通常很小：一个界面、一个接口、一条要换个做法的规则。跑整个测试套件，让每个需求都变得比它本身更大。这个套件有 2,248 个 Django 测试，四个进程跑大约三分半，单进程要 11 分钟，而每次编辑都会把它整个跑一遍，尽管其中几乎全部检查的都是这个需求根本没碰过的东西。只要有不相干的测试失败，或者运行单纯慢了点，功能就得等着。拖住它的，是功能需求之外的情况，而那个拖住它的东西，正是我自己的测试套件。</p>
<p>另一条路也好不到哪儿去：不跑全量，猜哪个测试文件覆盖了这次改动，偶尔猜错。这样是快，但不精确，而我发出去的每一个未经测试的改动，都是一个要做两遍的功能需求。</p>
<p>我等的测试应该恰好是这次改动能触及的那些，不多也不少。testmon 给我的这组测试来自记录，而不是猜测。全量套件照样在跑，只不过是在 CI 里，不会拖住我接下来手上的任何事。</p>

<h2 class="relative group">它怎么决定跑什么
    <div id="它怎么决定跑什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%83%e6%80%8e%e4%b9%88%e5%86%b3%e5%ae%9a%e8%b7%91%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>testmon 把每个 Python 文件切成代码块：模块的顶层、每个类、每个函数，嵌套函数的函数体会从外层代码块里切出去。它为每个代码块存一个校验和，从不存代码本身。测试运行时，coverage.py 记录它执行的代码行，testmon 记下这些行属于哪些代码块。没有静态分析，也不靠 import 去猜。一个测试依赖的，恰好就是它跑过的那些代码块。</p>
<p>拿这样一个模块来说：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p>一个只调用 <code>subtotal()</code> 的测试，依赖的是模块顶层和 <code>subtotal</code> 的函数体。<code>discount()</code> 随你怎么改写，testmon 都不会重跑这个测试，因为它从没执行过 <code>discount</code> 里的任何一行。而在 <code>subtotal</code> 里哪怕只改一个字符，它就会再跑一次，即使这个改动根本不可能改变结果。testmon 不会在副作用上耍聪明：你执行过的代码块变了，你就得跑。</p>
<p>它还会追踪代码周围的东西。记录里包括 Python 版本，以及已安装的包及其版本。它不看环境变量：同一份代码要在两个 settings 模块下跑的话，得用 <code>--testmon-env</code>（第 8 步）给它们起名字，否则它们共用一份记录。升级一下 Django，所有碰过 Django 的东西都会再次受影响，而在一个 Django 应用里，那就是全部。</p>

<h2 class="relative group">它看不到什么
    <div id="它看不到什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%83%e7%9c%8b%e4%b8%8d%e5%88%b0%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>testmon 追踪的是测试执行的 Python 代码。测试读取的其他文件它不追踪：模板、JSON、文本，磁盘上的任何别的东西。经网络访问的服务它也不追踪。而在测试开始前只跑一次的东西，它没法归到某个具体测试头上。</p>
<p>放在 Django 项目里，首先是迁移：它们只在创建测试数据库时跑一次，所以没有哪个测试的记录里包含它们（有新迁移时，下面的包装脚本会重建测试数据库）。其次是设置和启动代码，它们在 pytest 启动时运行，不属于任何一个测试。再就是模板和数据文件：模板不是 Python，所以改了模板，渲染它的测试一个都不会重跑。</p>
<p>我的测试套件还有几样自己特有的。有些测试读取一个 JSON fixture 文件，移动应用的测试也共用它。有些检查构建出来的 Web bundle，还有些检查同一个仓库里生成的静态网站的输出。改了其中任何一个，testmon 都会高高兴兴地告诉你什么都不用跑。所以下面的教程会用一个脚本把 testmon 包起来，这个脚本会留意这些文件，只要其中一个改了，就全部跑一遍。</p>

<h2 class="relative group">怎么把它加进 Django 项目
    <div id="怎么把它加进-django-项目" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%80%8e%e4%b9%88%e6%8a%8a%e5%ae%83%e5%8a%a0%e8%bf%9b-django-%e9%a1%b9%e7%9b%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>以下步骤假定你用 pytest 和 <a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>，依赖用 uv 管理。用 pip 的话，把 <code>uv add --dev</code> 换成 <code>pip install</code>，去掉 <code>uv run</code>。</p>
<p><strong>1. 安装。</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv add --dev pytest-testmon</span></span></code></pre></div></div>
<p><strong>2. 确保 pytest 能跑这套测试。</strong> testmon 是 pytest 插件，所以在 <code>python manage.py test</code> 下它什么都不做。pytest-django 需要知道设置模块，以及你的测试所用的文件命名模式：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># pytest.ini</span>
</span></span><span class="line"><span class="cl"><span class="k">[pytest]</span>
</span></span><span class="line"><span class="cl"><span class="na">DJANGO_SETTINGS_MODULE</span> <span class="o">=</span> <span class="s">myproject.settings</span>
</span></span><span class="line"><span class="cl"><span class="na">python_files</span> <span class="o">=</span> <span class="s">tests.py test_*.py *_tests.py</span>
</span></span><span class="line"><span class="cl"><span class="na">python_classes</span> <span class="o">=</span> <span class="s">Test* *Tests *TestCase</span></span></span></code></pre></div></div>
<p>如果这套测试一直只在 Django 自带的 runner 下跑过，先在 pytest 下跑一遍，把不一致的地方修好，再加 testmon。不然你就得同时调试两件事。</p>
<p><strong>3. 别把记录放进 git。</strong> testmon 会在 pytest 根目录写入 <code>.testmondata</code>。它是一个 SQLite 数据库，所以旁边还会出现 <code>-shm</code> 和 <code>-wal</code> 文件，下面的脚本还会再加一个时间戳文件。一个模式就能把它们全部覆盖：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># .gitignore
</span></span><span class="line"><span class="cl">.testmondata*</span></span></code></pre></div></div>
<p><strong>4. 先把整套测试记录一遍。</strong> testmon 在看过每个测试运行之前，什么都选不出来：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv run pytest --testmon</span></span></code></pre></div></div>
<p>在全新的检出里，第一次运行就是单进程跑完整个套件。在我这里，2,248 个测试大约要 12 分钟。从那以后，同一条命令就只跑受影响的测试。什么都没改的话，它什么都不跑。</p>
<p>有两个坑。在 testmon 2.2.0 及更早版本上，它需要 pytest 的 cache 插件：我第一次尝试时带着 <code>-p no:cacheprovider</code>，结果以 <code>KeyError: 'lf'</code> 崩掉了（2.3.0 会把缺失的 <code>--lf</code> 当作未设置）。另外，在 Python 3.14 或更高版本上，记录之前要先设置 <code>COVERAGE_CORE=ctrace</code>。原因在后面的“看起来全绿的那个 bug”一节里解释。这是下面脚本里最重要的一行。</p>
<p>更新（11 月 7 日）：11 月 2 日发布的 testmon 2.3.0 会自己强制使用 coverage 的 C tracer，所以在这个版本上，这行 export 只是多一道保险。如果你用的是 2.2.0 或更早的版本，就留着它。而故意弄坏点什么来验证的那一步，不管哪个版本都要做。</p>
<p><strong>5. 为它看不到的文件包一层。</strong> 正是这一步，让它可以放心地天天用。脚本保留一个时间戳文件，记的是上次运行开始的时间。只要有任何 testmon 追踪不到的文件比这个时间戳新，就全部跑一遍并重新记录。新的迁移只会让它重建测试数据库。其余情况就让 testmon 来选。删减后的示例：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/test-changed.sh: the tests a change can affect, or all when in doubt.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh            # affected tests</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh --all      # everything, re-recorded</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh -k orders  # anything else goes to pytest</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$0</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># coverage&#39;s C tracer: from Python 3.14 coverage defaults to sys.monitoring,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># which reports a line only the first time a process runs it, so testmon</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would credit each line to one test. Changing this means --all.</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">COVERAGE_CORE</span><span class="o">=</span>ctrace
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span>.testmondata.started
</span></span><span class="line"><span class="cl"><span class="nv">full</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="nv">rebuild</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;--all&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span>true<span class="p">;</span> <span class="nb">shift</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="o">[</span> ! -f .testmondata <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> ! -f <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;No record yet: running everything once.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Changed since the last run started, and not traceable per test:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># anything that isn&#39;t Python, plus settings and conftest.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">newer</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>find myapp myproject fixtures pytest.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">        -type f -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> ! -path <span class="s1">&#39;*/migrations/*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        <span class="se">\(</span> ! -name <span class="s1">&#39;*.py&#39;</span> -o -path <span class="s1">&#39;myproject/settings*&#39;</span> -o -name <span class="s1">&#39;conftest.py&#39;</span> <span class="se">\)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        ! -path <span class="s1">&#39;*/__pycache__/*&#39;</span> <span class="p">|</span> head -5<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$newer</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;Changed, and not traceable per test:&#34;</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">&#39;  %s\n&#39;</span> <span class="nv">$newer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A new migration needs a fresh test database, not a full run.</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find myapp -path <span class="s1">&#39;*/migrations/*.py&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">|</span> head -1<span class="k">)</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">rebuild</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Stamp the start, so an edit made while the tests run counts next time.</span>
</span></span><span class="line"><span class="cl"><span class="nv">started</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>mktemp .testmondata.run.XXXXXX<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --create-db alone deletes the database when the run ends; with</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --reuse-db as well, the rebuilt one is kept for next time.</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rebuild</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$full</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon-noselect --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">uv run pytest <span class="s2">&#34;</span><span class="si">${</span><span class="nv">args</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> -q <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 0: passed, or nothing selected. 1: some failed (still recorded).</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 5: nothing collected at all.</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> in 0<span class="p">|</span>1<span class="p">|</span>5<span class="o">)</span> mv <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">;;</span> *<span class="o">)</span> rm -f <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">5</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>这里面有两个细节值得解释。<code>--reuse-db</code> 是 pytest-django 的参数，用来在两次运行之间保留测试数据库。一旦一次运行只要几秒，这一点就很要紧：每次重新创建数据库，花的时间可能比测试本身还长。全量运行，或者有新迁移之后的那次运行，会再加上 <code>--create-db</code>，让数据库用上当前的 schema。<code>--reuse-db</code> 要和它一起留着：单独用 <code>--create-db</code> 时，pytest-django 会在运行结束时删掉数据库，下一次运行就得从头再建一遍。</p>
<p>还有，当 testmon 把所有测试都取消选中时，运行的退出码是 0，所以“什么都没改”算作通过。退出码 5 是 pytest 的 &ldquo;no tests collected&rdquo;（没有收集到测试），脚本把它也当作没问题。</p>
<p>注意清单里没有什么：<code>pyproject.toml</code> 和 lock 文件。我的第一版只要 lock 文件一变就全部跑一遍，结果这个慢吞吞的兜底大多数日子都会触发，整件事比以前还糟。testmon 本身就会追踪已安装包的版本，用不着帮这个忙。</p>
<p><strong>6. 给它单独的数据库和缓存。</strong> 如果同一台机器上还有别的东西在跑测试，比如第二个终端、一个编程 agent 或者 <code>manage.py test</code>，就让脚本指向它自己的测试数据库和它自己的缓存编号，这样保留下来的数据库永远不会撞上别人的运行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">DATABASE_URL</span><span class="o">=</span><span class="s2">&#34;postgresql://myapp:myapp@localhost:5432/myapp_changed&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REDIS_URL</span><span class="o">=</span><span class="s2">&#34;redis://localhost:6379/8&#34;</span></span></span></code></pre></div></div>
<p><strong>7. 每次运行前清空那个缓存。</strong> 这一条我是吃了亏才学到的。子集运行开始出现 <code>KeyError</code>，全量运行却没有。有些分类标签的缓存没有过期时间，于是一次运行保留下来的测试数据库，碰上了更早一次运行留下、还热着的缓存。在脚本开头清掉缓存，问题就解决了：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv run python -c <span class="s2">&#34;import os, redis; redis.Redis.from_url(os.environ[&#39;REDIS_URL&#39;]).flushdb()&#34;</span></span></span></code></pre></div></div>
<p>这正是 testmon 在做它文档里说过会做的事：每次跑不同的子集，会暴露出那些只是因为之前跑过什么才通过的测试。修复应该放在测试的 setup 里，而不是 testmon 里。</p>
<p><strong>8. 记住选择出错时用的参数。</strong></p>
<ul>
<li><code>--testmon-noselect</code> 跑所有测试，但把可能失败的排在前面。当你同时传了 <code>-k</code>、<code>-m</code>、<code>--lf</code> 或某个测试的 node ID 时，testmon 会自动切换到这个模式。</li>
<li><code>--testmon-forceselect</code> 只跑既受影响、又匹配你的 <code>-k</code> 或 <code>-m</code> 的测试。</li>
<li><code>--testmon-nocollect</code> 使用已有的记录，但不往里添加。在调试器下，或者 coverage 已经在运行时，会强制使用这个模式。</li>
<li><code>--no-testmon</code> 在它写进了 <code>addopts</code> 时，用来关掉一次运行。</li>
<li><code>--testmon-env=&lt;name&gt;</code> 在一个文件里保存多份独立的记录，适合要在两个设置模块下跑的测试套件。</li>
</ul>
<p>如果项目还有 JavaScript 那一侧，Jest 自带了同类功能。<code>jest --changedSince=main</code> 会跑那些 import 了自 <code>main</code> 以来改动过的任何东西的测试，它跟的是 import，而不是覆盖率。我把它加进了应用的 <code>package.json</code>，叫 <code>npm run test:changed</code>，这样两边是同一个习惯。改了一个工具模块，它从应用的 121 个测试文件里选中了 7 个。</p>

<h2 class="relative group">它能省下多少
    <div id="它能省下多少" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%83%e8%83%bd%e7%9c%81%e4%b8%8b%e5%a4%9a%e5%b0%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>包装脚本就位、并修好下一节说的那个 bug 之后，我计了一下时，包括启动时间：</p>
<table>
	<thead>
			<tr>
					<th>改了什么</th>
					<th style="text-align: right">跑的测试数</th>
					<th style="text-align: right">耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>什么都没改</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>改了一处首页横幅背后的代码</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>撤销那处改动</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>整个套件，作为对照（4 个进程）</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">约 3½ min</td>
			</tr>
	</tbody>
</table>
<p>日常的一次编辑，比并行跑整个套件快大约 25 倍；什么都没改的时候，快大约两百倍。</p>
<p>这份记录从更宽的角度说明了同一件事。在测试套件跑的 307 个非测试 Python 文件中，改动中位数的那个文件会波及 22 个测试，测试时间大约 8 秒。另一头是几个核心模块：几乎每个测试都会跑到它们，所以改了那里会波及 1,500 个左右的测试，testmon 会重跑套件的大部分。这才是正确的答案，不是失败。这些数字是按文件算的上限：testmon 按代码块工作，所以改了一个函数，只会选中跑过那个函数的测试。</p>
<p>代价出现在包装脚本退回全量运行的时候。一次全量运行和第一次一样，是单进程的记录，大约 12 分钟，比它取代的那个并行套件还慢。之所以是单进程，只是因为我还没试过把 testmon 和 pytest-xdist 一起用；它从 1.4 起就支持 xdist，下一个该量的就是这个。所以触发全量运行的文件清单，应该在不自欺的前提下尽量短。</p>

<h2 class="relative group">看起来全绿的那个 bug
    <div id="看起来全绿的那个-bug" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%9c%8b%e8%b5%b7%e6%9d%a5%e5%85%a8%e7%bb%bf%e7%9a%84%e9%82%a3%e4%b8%aa-bug" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我最早的数字比这些好看：改一处，6 个测试，3.3 秒。它们是错的，而屏幕上没有任何东西提示这一点。</p>
<p>从 Python 3.14 开始，coverage.py 默认使用一个新的核心，建立在解释器的 <code>sys.monitoring</code> 之上。这个核心只在一个进程第一次运行某一行时报告它。testmon 关于哪个测试跑了哪一行的记录，是从 coverage 得来的，所以每一行都只记在碰巧第一个跑到它的测试头上，别的测试一概没有。改一行八个测试共用的代码，testmon 只会选中其中一个。</p>
<p>发现它，是在我另一个更小的项目上，那个项目照搬了这套配置。在那里引入它的步骤之一，就是故意弄坏一行代码、对比结果，而数字对不上。回到这个套件，我做了同样的检查。我弄坏了首页横幅代码里的一行，也就是一张幻灯片的标题。直接跑 pytest，报告了 8 个失败的测试。testmon 选中了 4 个。另外 4 个坏掉的测试根本没跑，输出里也没有任何地方说它们被漏掉了。</p>
<p>修复只要一行，<code>export COVERAGE_CORE=ctrace</code>，让 coverage 回到经典的 C tracer。换了 tracer，记录也就不一样了，所以我重新记录了一遍：2,248 个测试，12 分 3 秒。之后，同样那种弄坏代码的改动，把它弄坏的 11 个测试全都跑了。</p>
<p>更新（11 月 7 日）：11 月 2 日发布的 testmon 2.3.0 现在会自己设置 C tracer，进程内和子进程里都会，所以新装的话不再需要这行 export。下一段里的检查仍然需要。</p>
<p>这是我最想先传下去的一条教训。一个选得太少的测试选择器，看起来和成功一模一样：测试更少，跑得更快，全是绿的。所以搭一个这样的东西时，要故意弄坏某个共用的东西，分别跑原本的完整套件和选择器，确认它们失败的是同一批测试。之后每当 Python 或 coverage.py 升级版本，再做一遍。</p>

<h2 class="relative group">编程 agent 在哪里用得上
    <div id="编程-agent-在哪里用得上" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%bc%96%e7%a8%8b-agent-%e5%9c%a8%e5%93%aa%e9%87%8c%e7%94%a8%e5%be%97%e4%b8%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>那个仓库里的改动，已经不全是我自己做的了。一个编程 agent，我用的是 Claude Code，做了很多小改动，而一个好的 agent 每次编辑后都会跑测试。面对一个 11 分钟的测试套件，它的两个坏选择和我当初一样：等，或者猜哪个测试文件要紧。</p>
<p>所以仓库里给 agent 的说明现在写着：工作时跑测试，默认就用这个包装脚本，要全量运行就加 <code>--all</code>。每次编辑后，agent 都能在几秒内拿到和我一样精确的那组测试。这也是第 6 步要给脚本单独的数据库和缓存的原因：忙的时候，同一台机器上不止一个会话在跑测试，而保留下来的数据库绝不能撞上别人的运行。</p>
<p>这些都不需要 testmon 提供任何 AI 专属的东西。还是同一份记录、同一套选择。只不过，当做改动的那一方改得非常多时，它就更重要了。</p>

<h2 class="relative group">为什么 CI 用的是更粗的版本
    <div id="为什么-ci-用的是更粗的版本" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88-ci-%e7%94%a8%e7%9a%84%e6%98%af%e6%9b%b4%e7%b2%97%e7%9a%84%e7%89%88%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>testmon 没有 CI 模式。它在 runner 上和在笔记本上表现一样，而它的作者写道，在 CI runner 之间共享或缓存 <code>.testmondata</code>，效果远不如他们的托管服务 testmon.net。缓存下来的记录还会带着上面说的所有盲区，再加上我的 CI 是用 Django 自带的 runner、四个进程跑这套测试的，所以我没在 CI 里用 testmon。</p>
<p>CI 拿到的，是同一个思路的文件级版本。检查工作流现在先跑一个小 job，对这次推送做 diff，判断它碰到了仓库的哪一半：Python API、移动应用、两者都有，或者都没有。在这之前，每次推送 CI 都会把 Python 和应用两个 job 都跑一遍，只改文档的推送也不例外。现在只改文档的推送什么都不跑，只改应用的推送会跳过 Django 测试套件。我拿五个真实的提交试跑了这个 job：只改文档的那个什么都没跑，只改应用的那个跳过了 Python job，只改后端的那个跳过了应用 job。删减后的示例：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">python</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.diff.outputs.python }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">diff</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">needs.changes.outputs.python == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span></span></span></code></pre></div></div>
<p>真实的版本在没有可比较的范围时，也会全部跑一遍：新分支、强制推送（force push），或者手动运行。</p>
<p>盲区还是同一个，只是换了个形式。testmon 看不到 JSON 文件，路径过滤器也看不到，除非你把它列出来。所以这个模式里明确写出了共享的 fixture、构建出来的 bundle 和生成的网站，还加了一条注释，请改它的人让这份清单保持如实。文件级的版本更粗糙，但它出错时偏向安全的一边：Python 那边只要有任何改动，就跑整个套件。</p>

<h2 class="relative group">值得吗？
    <div id="值得吗" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%80%bc%e5%be%97%e5%90%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>对一个要跑好几分钟的测试套件来说，值得。真正的代价在于它看不到的东西，而那正是包装脚本的用处。即便如此，testmon 该待的地方是笔记本上的开发循环。只要 Python 那边有改动，CI 照样跑整个套件，部署前我信任的是那一次运行。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>GitHub Actions CI 里的 Docker Compose：我换成了什么，以及现在在跑的流水线</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>我 2026 年在 GitHub Actions 上跑的 CI/CD：CI 里用服务容器取代 Docker Compose，缓存按每步耗时来选，部署全是脚本。</description>
      <content:encoded><![CDATA[<p>CI/CD 流水线，就是一次改动从 <code>git push</code> 走到生产环境的自动化路径：CI 在干净的机器上构建并测试每一次推送，CD 用一个可重复的步骤把通过的东西发布出去。在 GitHub Actions 上它是 <code>.github/workflows/</code> 下的一个 YAML 文件，我的流水线故意做得很薄：检查就是我在笔记本上敲的那些命令，每一种部署都是脚本或 CLI，离开 GitHub 也照样能跑。</p>
<p>如果你是来找「怎么在 GitHub Actions 里跑 Docker Compose」的：我这么干了两年，然后停了。现在 CI 把同样的 Postgres 和 Valkey 镜像声明为 <code>services:</code>，直接在 runner 上跑同样的 <code>uv run …</code> 和 <code>npm run …</code>；Compose 回归成开发者本地跑的东西。下面是原因，连同文件。</p>
<p>2024 年我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2024/2024-06-02-simplifyingcicd/" >用 Docker Compose 做 CI/CD，别把逻辑写进平台专属的 YAML</a>，意思是构建逻辑放进 Compose，CI 工具只负责调用它。两年后，我把自己真正用来发布的仓库全部翻了一遍。有工作流的仓库一共四个，提交者都只有我一个人，而这些工作流里，已经没有一个在调用 <code>docker compose</code> 了。原则留下来了，做法没有。下面是取而代之的东西：删减过的示例文件、GitHub 记录下的每一步耗时，以及那些没有 CI/CD 示意图期望的那么自动化的部分。</p>

<h2 class="relative group">我跑的所有流水线，一张表
    <div id="我跑的所有流水线一张表" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e8%b7%91%e7%9a%84%e6%89%80%e6%9c%89%e6%b5%81%e6%b0%b4%e7%ba%bf%e4%b8%80%e5%bc%a0%e8%a1%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>流水线</th>
					<th>由谁触发</th>
					<th>CI 检查什么</th>
					<th>发布到哪里</th>
					<th>谁来发布</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>这个博客和一个客户的 Hugo 网站</td>
					<td>每次推送，任何分支</td>
					<td><code>hugo --gc</code> 能否构建</td>
					<td>Cloudflare Pages：<code>main</code> 是生产，其他分支得到预览 URL</td>
					<td>没人；推送就是发布</td>
			</tr>
			<tr>
					<td>我那个集市应用的 API</td>
					<td>一个部署脚本，手动运行</td>
					<td>每次推送到 <code>main</code> 时跑代码检查、类型检查和 Django 测试</td>
					<td>Fly.io，滚动部署</td>
					<td>我</td>
			</tr>
			<tr>
					<td>同一个应用的前端：Web、OTA 更新、商店构建</td>
					<td>几个发布脚本和本地商店构建，手动</td>
					<td>每次推送到 <code>main</code> 时跑 TypeScript 和 Jest</td>
					<td>对象存储、一个 OTA 更新服务，以及两家应用商店</td>
					<td>我</td>
			</tr>
			<tr>
					<td>pyhtml2text，我的开源 Python 包</td>
					<td>每次推送、Pull Request 和 tag</td>
					<td>15 个构建目标的 wheel，其中大部分会跑测试</td>
					<td>PyPI，打 tag 时</td>
					<td>tag</td>
			</tr>
	</tbody>
</table>
<p>用教科书的话说，两个 Hugo 网站是持续部署；而那个应用（我做的一个集市兼社区应用）是持续集成加上一条命令就能完成的发布，也就是持续交付：检查会告诉我哪里不对，但不会拦着我部署。这是有意为之。一次 OTA 更新会送到所有装着目标二进制版本的手机上，所以发布它应该是一个决定，而不是合并的副作用；而 API 是数据库迁移运行的地方。我另外两个 Django 应用，压根没有工作流。</p>

<h2 class="relative group">CI 这一半：一个工作流，两个 job
    <div id="ci-这一半一个工作流两个-job" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ci-%e8%bf%99%e4%b8%80%e5%8d%8a%e4%b8%80%e4%b8%aa%e5%b7%a5%e4%bd%9c%e6%b5%81%e4%b8%a4%e4%b8%aa-job" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用的 CI 只有一个文件，<code>.github/workflows/checks.yml</code>。10 月 11 日之前，连它都没有。下面是删减后的示例，看个大致样子：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>两个 job 并行运行。到目前为止成功的 35 次运行里，应用 job 的中位数大约七分钟，Python job 大约十二分半。</p>
<p><code>services:</code> 这一块，就是 Compose 文件搬去的地方，下一节细说。环境变量只有寥寥几个，外加一个开关；其余的一切，都会回落到开发者 <code>docker compose up</code> 时拿到的开发默认值，所以测试不需要任何密钥。为了确认真是这样，我只用这套环境、别的什么都不带，跑了一部分测试。</p>
<p>CI 用的解释器和我的笔记本、生产镜像是同一个，也就是 uv 自己构建的 Python 3.15.0，因为 <code>uv sync</code> 会读 <code>.python-version</code>，并下载正是那个版本。（我真实的文件里还给 setup-uv 传了一个 <code>python-version-file</code> 输入，其实什么都没做：setup-uv v6 没有这个输入，每次运行都带着一条这样说的警告。写这篇文章时我才发现。）</p>
<p>解释器一致的好处，在工作流上线八天后就显出来了。10 月 19 日我打开了 3.15 的 JIT 编译器，开启后的第一次运行就有一个测试失败：一句再普通不过的 <code>yield from</code>，在每次保存都会经过的代码路径上，抛出了 <code>AttributeError: 'list' object has no attribute 'send'</code>。同一个提交关掉 JIT 就能通过。我的 Mac 是 arm64，从没碰到过它；而 runner 是 x86_64，和生产机器一样。JIT 现在在 CI 和 Dockerfile 里都重新关掉了，这个 bug 从没到过生产环境。这就是 python/cpython#155823，上游在 10 月 13 日、也就是 3.15.0 发布之后修掉了它。</p>
<p>其实第一次运行也失败了，原因更无聊。Expo 在 <code>expo start</code> 时会生成 <code>expo-env.d.ts</code>，而这个文件被 gitignore 了，所以我用过的每个工作副本里都有它，全新的检出里却没有。TypeScript 报了五个本地从没出现过的错误。补上生成这个文件的那一步，就修好了。</p>
<p><code>concurrency</code> 会在同一分支来了更新的推送时，取消之前的运行；到目前为止被取消的十二次运行，全都是这么结束的。另外还有一个提交前钩子（pre-commit），会跑代码检查，暂存了 Python 或 TypeScript 文件时再跑类型检查，所以大部分错误根本走不到推送那一步。</p>

<h2 class="relative group">Docker Compose 文件去哪儿了
    <div id="docker-compose-文件去哪儿了" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#docker-compose-%e6%96%87%e4%bb%b6%e5%8e%bb%e5%93%aa%e5%84%bf%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>下面是这个应用的 <code>docker-compose.yml</code> 里 CI 关心的那部分，同样是示例。真实文件里服务更多，环境变量也多得多。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p>把它和 <code>checks.yml</code> 里的 <code>services:</code> 放在一起看，是同样的两个镜像：Postgres 17（这里是 Alpine 版，CI 里是 Debian 版）和 <code>valkey/valkey:8-alpine</code>。现在两者的关系就是这样：Compose 文件是开发者跑的东西；CI 把同样的镜像声明为服务容器，然后直接在 runner 上跑开发者跑的那些命令，<code>uv run …</code> 和 <code>npm run …</code>。</p>
<p>和 2024 年那篇对照，我最在意的部分留了下来：CI 工具依然很薄，检查和部署都是我自己就能跑的命令。没留下来的，是让 CI 去调用 Compose。</p>
<p>这个应用的 CI 根本不构建 Docker 镜像；镜像在部署时由 Fly 的构建机构建一次，CI 只需要回答代码好不好，工具链有缓存的话，这只要六秒的安装。另外，Compose 文件会把工作副本 bind mount 进去、灌入种子数据，这些都不是一次测试运行该做的事。所以 CI 本来还得有一份自己的 Compose 文件，也就是同一组服务的第二份描述，靠手工保持同步。</p>
<p>还有，2024 年那个例子里有个我当时没发现的 bug。<code>docker compose build</code> 只构建镜像，从不运行服务的 <code>command</code>，所以那个文件里的 <code>npm test</code> 在 CI 里一次都没跑过，工作流不管测试过没过都是绿的。</p>
<p>那篇文章最担心的风险，也就是换 CI 供应商，始终没有发生。真正发生的迁移在另一头：这个博客和那个客户的网站换了托管，搬到了 Cloudflare Pages，博客这边只是新写了一个 45 行的工作流，因为构建本来就只是一条命令。至于我当年警告过的三百行工作流？我确实有一个，pyhtml2text 的。它之所以长，是因为 wheel 必须分别在 Linux、macOS 和 Windows 的 runner 上、为多种架构构建，也正因为如此，这是唯一一个平台专属 YAML 才是对的工具的活儿。</p>

<h2 class="relative group">缓存：先看每一步的耗时
    <div id="缓存先看每一步的耗时" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%bc%93%e5%ad%98%e5%85%88%e7%9c%8b%e6%af%8f%e4%b8%80%e6%ad%a5%e7%9a%84%e8%80%97%e6%97%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>GitHub 记录了每一步的耗时，用 <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> 就能拿到。下面是这个应用最近一次成功的运行，10 月 20 日：</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>步骤</th>
					<th>秒</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>启动 Postgres 和 Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>，缓存已热</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django 测试</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>，缓存已热</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>两个依赖缓存都开着，而且都是便宜的那部分。GitHub 为这个仓库保存的 uv 缓存只有 200 KB 左右，因为 setup-uv 会清掉 uv 重新下载就行的东西；npm 的接近 400 MB。时间花在测试上，而测试已经四路并行了。想让运行更快的话，我会去改测试套件，而不是缓存。</p>
<p>博客的情况正好相反。Hugo 处理过的图片放在被 gitignore 的 <code>resources/_gen</code> 里，所以每台新的 runner 都要把封面、正文图片和占位图全部重新编码一遍。下文的工作流用 <code>actions/cache</code> 把它们恢复回来，键是图片本身的哈希，对不上时退回到最新的旧缓存，因为 Hugo 只处理缺的部分。加缓存之前的最后八次运行，构建这一步要 33 到 76 秒；之后的 26 次是 6 到 20 秒，只有一次例外，94 秒。而且同一个提交还加了占位图和第二种卡片尺寸。</p>
<p>这个应用的镜像在 Fly 的构建机上构建，依赖文件先于代码复制进去，uv 的下载缓存放在 BuildKit 的缓存挂载里（<code>RUN --mount=type=cache,target=/root/.cache/uv</code>）。不过最大的改进并不是缓存。旧的 <code>.dockerignore</code> 是黑名单，漏掉了一个放额外 git worktree 的文件夹，每个 worktree 都带着自己的依赖和构建产物，结果每次部署都有 27.7 万个文件、7.7 GB 被传到构建机。改写成白名单之后，构建上下文大约 40 MB。这种写法的示例：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<h2 class="relative group">密钥，放在尽量少的地方
    <div id="密钥放在尽量少的地方" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%af%86%e9%92%a5%e6%94%be%e5%9c%a8%e5%b0%bd%e9%87%8f%e5%b0%91%e7%9a%84%e5%9c%b0%e6%96%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>密钥放在三个地方。GitHub Actions 的密钥用于工作流要做的事，两个 Hugo 仓库各自只有一个，<code>CLOUDFLARE_API_TOKEN</code>。Fly 的密钥是应用运行时的配置，用 <code>flyctl secrets set</code> 设置，从不经过 Actions。最后，我笔记本上被 gitignore 的 <code>.env</code> 里，放着部署脚本清 Cloudflare 缓存用的令牌。</p>
<p>PyPI 完全不需要密钥。pyhtml2text 用的是可信发布（trusted publishing）：PyPI 接受 GitHub 签发给工作流的短期 OIDC 令牌，所以发布 job 只申请 <code>id-token: write</code>，仓库的密钥页面是空的。没有可泄露的，也没有要轮换的。</p>
<p>令牌只给它需要的权限。这个应用在 GitHub 上的 Fly 令牌，服务于一个只列出机器的定时检查，所以是用 <code>fly tokens create readonly</code> 创建的。Pages 部署只申请 <code>contents: read</code> 和 <code>deployments: write</code>，别无其他。</p>
<p>输入一律经过 <code>env:</code>，绝不直接塞进脚本。OTA 工作流接收自由填写的发布说明。如果在 <code>run:</code> 里写成 <code>${{ inputs.notes }}</code>，文本会在 bash 读取之前原样贴进脚本，于是一条带引号和分号的说明就成了代码。作为环境变量传进去，它就始终只是数据。这种写法的示例：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>有些密钥干脆不放在 GitHub 上。有一份定时报告需要一个对应用登录后端有管理员权限的密钥，所以它从定时工作流搬到了我 Mac 上的一个定时任务。工作流仍然留着作为手动备用，只是会略过需要那个密钥的部分。</p>

<h2 class="relative group">部署：每次推送、靠脚本、按 tag
    <div id="部署每次推送靠脚本按-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%83%a8%e7%bd%b2%e6%af%8f%e6%ac%a1%e6%8e%a8%e9%80%81%e9%9d%a0%e8%84%9a%e6%9c%ac%e6%8c%89-tag" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hugo 网站每次推送都部署
    <div id="hugo-网站每次推送都部署" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hugo-%e7%bd%91%e7%ab%99%e6%af%8f%e6%ac%a1%e6%8e%a8%e9%80%81%e9%83%bd%e9%83%a8%e7%bd%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这是博客工作流的示例版本，省掉了第一次运行时创建 Pages 项目的那一步：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> 让 <code>main</code> 成为生产，其他每个分支都得到预览 URL。那个客户网站的工作流和它是同一个结构，只是在构建前多了一行 <code>rm -rf public</code>，因为那边的 <code>public/</code> 是提交进仓库的，不这样做的话，已删除的页面会一直留在上传内容里。</p>
<p>Hugo 的版本只在这里固定，别处都不固定。10 月份一次包管理器升级把我笔记本上的 Hugo 升到了 0.168，比主题支持的最新版本还高一个。部署一直停在 0.165，毫无察觉。</p>
<p>而构建本身就是测试。9 月 14 日，某个分支的预览部署连续失败了三次：那个分支上的一篇文章，用 Hugo 的 <code>ref</code> 链接到了一篇还是草稿的文章。CI 和生产一样，构建时不包含草稿，所以这个链接什么都指不到，Hugo 以 <code>REF_NOT_FOUND</code> 停了下来。在此期间，<code>main</code> 照常部署。</p>

<h3 class="relative group">应用的 API 靠脚本部署
    <div id="应用的-api-靠脚本部署" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ba%94%e7%94%a8%e7%9a%84-api-%e9%9d%a0%e8%84%9a%e6%9c%ac%e9%83%a8%e7%bd%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>部署脚本的核心部分，示例如下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>以及 <code>fly.toml</code> 里对应的几行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>镜像只构建一次，以提交和时间组成的标签推到 Fly 的镜像仓库，再按这个标签部署，所以滚动上线的正是构建出来的那个东西。</p>
<p>让它做到零停机的，是先唤醒一台备用机器。<code>max_unavailable = 1</code> 只计算正在运行的机器，而这个应用空闲时一台运行中的机器都不留（挂起的机器大约一秒就能恢复）。所以以前那台唯一醒着的机器，会和睡着的机器在同一批里被替换掉，身后没有一台热着的机器。9 月 28 日，我用探针持续请求源站：直接 <code>flyctl deploy</code> 时出现了 7 次超时和一段 7.3 秒的空白；而用脚本部署时，先构建、在滚动开始前就有一台机器醒着，2,514 个请求一个都没失败。</p>
<p>脚本的其余部分，会在错误告警所在的团队聊天频道里留下部署标记，这样一阵错误涌来时，可以对照刚刚改了什么；然后清掉 Cloudflare 缓存，再把机器预热一遍。Web 前端完全不经过镜像，这部分我写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/" >前后端分开部署：不重新部署 API 也能发布 Web 前端</a>里。</p>
<p>迁移不是流水线的一个步骤。Fly 的 <code>release_command</code> 在我这里会卡住，所以由第一台用新镜像启动的机器拿锁并执行迁移，其他机器等着。因此每次迁移都必须兼容仍在运行的代码：一次部署里加列，下一次部署不再使用它，第三次再删掉。</p>
<p>这个脚本里没有哪一处非得在我的笔记本上不可，但有几个只在本地的文件把它留在了那里：Cloudflare 凭据，以及一份最后部署了什么的记录，全新的 runner 上不会有它们。它还会把未提交的改动也一起部署，并在聊天频道里说明这一点，而 Web 的发布脚本会拒绝不干净的工作区。</p>
<p>在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >每个分支一个预览环境</a>里我写过，这些事手工来做，只有你一个人时没问题；从第二个开发者开始，它就必须因为 git 里发生了什么而发生。这个应用现在还是我一个开发者。那篇文章里的分支环境工作流，还留在我当初搭建它们的那个分支上。</p>
<p>OTA 工作流只能手动运行，因为发布应该是一个决定，也因为在有部分发布（partial rollout）进行中时，更新服务会拒绝新的发布，换成推送触发的话，每次金丝雀发布都会撞上。这个工作流一次都没跑过。到目前为止，每一次 OTA 更新都是从我的笔记本上，通过工作流调用的同一个脚本发出去的。保持工作流轻薄的理由，一句话就是这个：不管发布从哪里开始，跑的都是同一个脚本。</p>

<h3 class="relative group">pyhtml2text 按 tag 发布
    <div id="pyhtml2text-按-tag-发布" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pyhtml2text-%e6%8c%89-tag-%e5%8f%91%e5%b8%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>发布 job 如下，包名换成了通用的名字：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>发布 job 会等所有构建 job 完成，只有 tag 才会真正发布；手动运行会构建并生成证明（attestation），但什么都不上传。<code>actions/attest</code> 记录每个 wheel 是由哪个工作流构建的，<code>uv publish --trusted-publishing always</code> 用 OIDC 令牌而不是密码来上传。这个包本身，我写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/" >一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包</a>里。</p>

<h2 class="relative group">定时工作流只是尽力而为
    <div id="定时工作流只是尽力而为" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%9a%e6%97%b6%e5%b7%a5%e4%bd%9c%e6%b5%81%e5%8f%aa%e6%98%af%e5%b0%bd%e5%8a%9b%e8%80%8c%e4%b8%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用有一个定时工作流，检查 Fly 启动的机器有没有超过基线，不检查的话，第一次发现会是在账单上。它靠故意失败来报警，因为定时运行失败时 GitHub 会给仓库所有者发邮件。</p>
<p>它设的是 <code>cron: '*/15 * * * *'</code>，一天 96 次。可过去两周里，它每天只跑了三到八次，两次运行之间间隔的中位数大约三小时四十分。GitHub 文档给出的唯一解释是负载：Actions 忙的时候，定时运行可能会延迟，忙到一定程度还可能被丢掉。对持续的扩容来说，几个小时足够了。对于必须在几分钟内报警的东西，GitHub 的 cron 不是监控。</p>

<h2 class="relative group">我会问一条流水线的几个问题
    <div id="我会问一条流水线的几个问题" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e4%bc%9a%e9%97%ae%e4%b8%80%e6%9d%a1%e6%b5%81%e6%b0%b4%e7%ba%bf%e7%9a%84%e5%87%a0%e4%b8%aa%e9%97%ae%e9%a2%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不管你是在搭新的，还是在审视现有的：</p>
<ol>
<li>如果 GitHub Actions 停摆一整天，你还能发布吗？不能的话，你的发布逻辑就在 YAML 里，2024 年那篇依然适用。</li>
<li>CI 跑的解释器、CPU 架构和数据库大版本，和生产一致吗？我的 CI 能抓到一个编译器 bug，只是因为 runner 和生产都是 x86_64，而我的笔记本不是。</li>
<li>全新的检出能通过吗？通不过的话，通常是那些自动生成、又被 gitignore 的文件在作怪。</li>
<li>一次运行的时间到底花在哪儿？加缓存之前，先看每一步的耗时。</li>
<li>每个密钥放在几个地方？它真的需要放在 GitHub 上吗？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包：给 Python 用的快速 HTML 转 Markdown</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>开源</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>性能</category>
      <description>为了给 AI agent 把 HTML 转成 Markdown，我需要把相对链接解析成绝对链接。上游的 Rust crate 没法接受我的 pull request，因为它是用 LLM 写的，于是我 fork 了它，发布了 pyhtml2text：带 base_url 的 Python 绑定、新的 html2markdown、用 profile-guided 优化构建的 wheel，以及一个打过补丁、能在线性时间内解析深层嵌套 HTML 的解析器。本文讲它为什么存在、里面有什么，以及我怎么检查输出是否正确。</description>
      <content:encoded><![CDATA[<p>10 月 15 日，我在 PyPI 上发布了 <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a>。它把 HTML 转成纯文本或 Markdown，底层是 Chris Emerson 写的 Rust crate <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>。在我测过的页面上，转 Markdown 的速度是常见 Python 转换器的 3 到 21 倍。它还能根据页面的 URL 解析相对链接，这样输出里的链接离开原页面后依然能用。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>它最初是给 rust-html2text 提的一个 pull request。维护者因为它是用 LLM 写的而关闭了它，所以 pyhtml2text 改为基于一个 fork 运行。这篇文章讲事情的经过、fork 和这个包里有什么，以及我怎么确认生成的 Markdown 是对的。</p>

<h2 class="relative group">为什么需要它
    <div id="为什么需要它" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e9%9c%80%e8%a6%81%e5%ae%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我做的一个集市兼社区应用，收到 <code>Accept: text/markdown</code> 的请求时会返回 Markdown 而不是 HTML，给那些不想在导航栏和页脚里翻找内容的 AI agent 和 LLM 客户端用。边缘那一侧的做法我在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >关于爬虫和缓存的文章</a>里写过。由应用数据生成的页面，HTML 只是一个空的 JavaScript 外壳，所以直接生成 Markdown。其余都是静态 HTML（营销页、法律条款和博客），这部分要经过转换。</p>
<p>转换的麻烦在于链接。静态站点的页面里到处都是 <code>/en/posts/...</code> 和 <code>../setup/</code> 这样的链接。在浏览器里没问题，因为浏览器知道它们来自哪个页面。读 Markdown 的 agent 没有这个上下文，所以它没法打开 <code>/login</code>，它需要的是 <code>https://example.com/login</code>。</p>
<p>纯 Python 的 <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> 有一个 <code>baseurl</code> 选项专门做这件事，我一开始用的就是它。但它很慢，生成的 Markdown 也有一些问题，后面会讲到。rust-html2text 速度快、测试充分，但它没有办法解析链接。</p>

<h2 class="relative group">先问上游
    <div id="先问上游" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%88%e9%97%ae%e4%b8%8a%e6%b8%b8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>8 月 29 日，我提了 <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a>，写明了使用场景和我想要的行为：按 RFC 3986 解析，绝对 URL 以及 <code>mailto:</code>、<code>data:</code> 和 <code>#fragment</code> 链接保持不变。几分钟后，我提了 <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">pull request #288</a>。它加了一个 <code>Config::base_url(...)</code> 选项，我在 issue 里说明这只是一个起点，如果维护者想换一种做法，我完全理解。</p>
<p>10 月 11 日，维护者关闭了这个 PR：</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn&rsquo;t previously stated)</p>
</blockquote><p>（大意：感谢贡献，但我无法接受，因为它看起来是 LLM 生成的。我已经在 README.md 里加了一条简短的政策，之前没有写明。）</p>
<p>并在 issue 里回复：</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>（大意：这是个合理的功能请求，谢谢！PR 看起来是 LLM 写的，我无法接受，所以关闭了，但感谢你的尝试。如果能提供一小段 HTML 示例和期望的输出，肯定有助于实现这个功能！）</p>
<p>现在 README 里有这样一条政策：</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>（大意：由于版权状态尚不明确，本项目目前无法接受由生成式 AI/LLM 产出的代码贡献。）</p>
<p>维护者的判断没错：那段代码确实是我用 AI 编程助手写的。我也不觉得这条政策不合理。LLM 输出的版权归属还没有定论，而把它合进一个许多项目都依赖的 MIT crate，风险是由维护者来承担的。这是对方的项目，而且回复也很客气。那个功能请求现在仍然开着。</p>
<p>不过我还是需要这个功能，而跟维护者争论对方自己定的贡献政策，并不是拿到它的好办法。所以我把改动留在一个 fork 上，在它之上构建了 Python 包，两者都清楚写明了自己不是上游。</p>

<h2 class="relative group">fork 里有什么
    <div id="fork-里有什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#fork-%e9%87%8c%e6%9c%89%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>fork 是我那个 rust-html2text fork 里的 <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> 分支</a>。为了能干净地 rebase 到上游，我只保留了三个小提交。最主要的是 PR 里那个 <code>Config::base_url</code> 选项：按 <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a> 解析相对链接，不传 base URL 时输出完全不变。另一个提交让 crate 不再每次调用都重建样式规则，短片段的耗时从 7.3 µs 降到 5.6 µs。大页面上这点差别可以忽略，但调用几千次就积少成多了。第三个只是把下文 Markdown 转换器需要的部分从 crate 里导出来。</p>

<h2 class="relative group">Python 包
    <div id="python-包" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#python-%e5%8c%85" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a> 用 PyO3 和 maturin 构建，提供两个函数：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code> 就是 rust-html2text 的纯文本渲染器，它是为终端和电子邮件的 <code>text/plain</code> 部分设计的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>关于这些绑定，有几点可能不那么显而易见：</p>
<ul>
<li><strong>两个函数都接受 <code>str</code> 或 UTF-8 的 <code>bytes</code></strong>，所以可以直接传 <code>response.content</code>，不用先解码。输入是借用的，不会复制。</li>
<li><strong>转换期间会释放 GIL。</strong> 从头到尾都是纯 Rust，所以多线程能用满多个核。模块还声明了自己不需要 GIL，因此在 free-threaded 的 Python 3.14t 上，导入它时不会把 GIL 重新打开。</li>
<li><strong>panic 会变成 <code>ValueError</code>。</strong> PyO3 默认抛出的是 <code>PanicException</code>，而 <code>except Exception</code> 捕获不到它。一个以为自己已经处理好转换错误的 web worker 会因此崩掉，所以绑定层把它捕获下来，重新抛成 <code>ValueError</code>。</li>
<li><strong>一共有 30 个 wheel：</strong> CPython 3.10 及以上的 abi3 wheel，加上 free-threaded 3.14t 的 wheel，覆盖 Linux（glibc 和 musl）、macOS 和 Windows 上 maturin 的 CI 能构建的所有架构。所以安装时谁都不需要 Rust 工具链。</li>
</ul>

<h2 class="relative group">让它变快
    <div id="让它变快" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%a9%e5%ae%83%e5%8f%98%e5%bf%ab" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>0.2.0 在真实页面上比 0.1.0 快 1.5 到 1.7 倍，短片段上快一倍，输出逐字节一致。大部分提升来自配置文件引导优化（PGO）：CI 先构建一个插桩版 wheel，在一组由文档页面和 RFC 组成的训练集上跑一遍，再用得到的 profile 构建正式的 wheel。在训练集以外的页面上大约快 25%，只在 CI 能原生运行插桩构建的平台上启用。剩下的来自换用 mimalloc 分配器（大量小块分配时它比系统分配器强得多），以及 fork 里那个样式规则的改动。</p>
<p>为了确认提速没有改变任何输出，我把一批真实页面用每一组选项都转换一遍，记录输出的哈希，还加上了空输入、极深嵌套之类的边界情况。每个改动都必须让所有哈希保持不变。</p>
<p>下面是在 Apple Silicon Mac 上转 Markdown 的中位耗时：</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>在线百科条目（1 MB）</th>
					<th>新闻网站首页（370 KB）</th>
					<th>Python 文档页（245 KB）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>（纯 Python）</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>转纯文本：</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>在线百科条目（780 KB）</th>
					<th>新闻网站首页（370 KB）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>（纯 Python）</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">深层嵌套的 HTML，以及给 html5ever 打的补丁
    <div id="深层嵌套的-html以及给-html5ever-打的补丁" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b7%b1%e5%b1%82%e5%b5%8c%e5%a5%97%e7%9a%84-html%e4%bb%a5%e5%8f%8a%e7%bb%99-html5ever-%e6%89%93%e7%9a%84%e8%a1%a5%e4%b8%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>没人会手写 40,000 层嵌套的列表。但如果你要转换不是自己写的页面，迟早会碰到某个恶意的或者坏掉的页面给你来这么一下。用原版解析器，解析 40,000 层嵌套的 <code>&lt;ul&gt;&lt;li&gt;</code> 要 <strong>6 秒</strong>，也就是一个 worker 被一个请求卡住 6 秒。</p>
<p>原因在 rust-html2text 使用的 HTML5 解析器 <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>。大多数块级开始标签都要检查是否有一个打开的 <code>&lt;p&gt;</code> 元素处于 &ldquo;button scope&rdquo; 之内，而 html5ever 每次都要把打开元素的栈从头到尾走一遍。嵌套越深，每个标签花的时间越长，总时间随深度的平方增长。</p>
<p>这个包附带了打过一个补丁的 html5ever 0.39.0。补丁让这个答案在元素打开和关闭时随时更新，所以检查的耗时不再取决于嵌套深度。同一个文档现在 <strong>0.14 秒</strong>就解析完了。为了确保补丁没有改变别的行为，我在 html5lib 的全部树构建测试、约 20 个真实页面和 30 万个随机标签汤文档上，对比了原版和补丁版解析器生成的 DOM，结果完全一致。</p>
<p>出于同样的原因，Markdown 转换器也设了上限：任何输入都不会撑爆线程栈；引用块和列表超过 32 层就不再缩进，免得输出按平方膨胀；展开后网格超过 100,000 个单元格的表格，只输出单元格内容。</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>0.3.0 版加入了 <code>html2markdown</code>，这个包的大部分工作都花在它上面。rust-html2text 的渲染器输出的文本看起来像 Markdown，但它是按终端宽度折行的，并不打算被解析。所以 <code>html2markdown</code> 只借用它的解析器，输出则自己来写：<a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>，外加 GitHub 风格的管道表格和 <code>~~删除线~~</code>：</p>
<ul>
<li><code>#</code> 标题、<code>-</code> 列表、<code>_强调_</code> 和 <code>**加粗**</code>。</li>
<li>行内链接和图片，给了 <code>base_url</code> 时转成绝对地址。如果一个链接里只有图标，就用它的 <code>aria-label</code> 或 <code>title</code> 作为链接文字。</li>
<li>围栏代码块，页面标明了语言时（<code>class=&quot;language-python&quot;</code>）会带上语言标记。</li>
<li>管道表格。跨行或跨列的单元格变成空单元格，没有表头行的表格会补一个空表头，用来做页面布局的表格则写成普通段落。</li>
<li>不折行。隐藏内容（<code>hidden</code>、<code>aria-hidden</code>、<code>display: none</code>）、脚本、样式和表单控件都会被去掉。</li>
</ul>

<h3 class="relative group">只转义需要转义的
    <div id="只转义需要转义的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8f%aa%e8%bd%ac%e4%b9%89%e9%9c%80%e8%a6%81%e8%bd%ac%e4%b9%89%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>有些 HTML 转 Markdown 的工具会把所有可能是 Markdown 语法的字符都转义掉。比如 markdownify 默认会转义每一个 <code>_</code> 和 <code>*</code>。这样的输出是正确的，但满篇都是 <code>snake\_case</code> 和 <code>5 \* 3</code>，人读起来费劲，对模型来说也是浪费 token。pyhtml2text 只在 CommonMark 解析器会把某个字符当成语法的地方才转义它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</span>)</span></span></code></pre></div></div>

<h3 class="relative group">韩语助词前的加粗
    <div id="韩语助词前的加粗" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%9f%a9%e8%af%ad%e5%8a%a9%e8%af%8d%e5%89%8d%e7%9a%84%e5%8a%a0%e7%b2%97" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这个问题来自我那个应用自己的页面。韩语的助词紧跟在名词后面，中间没有空格，所以一个带括号的加粗词语是这样的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>最直接的 Markdown 写法是 <code>**타이타닉(Titanic)**의</code>，markdownify 输出的就是这个，但它不起作用。CommonMark 的 flanking 规则规定，<code>**</code> 跟在标点后面时，只有后面紧接空格或另一个标点才能闭合，而 <code>의</code> 是一个字母，所以这些星号会原样留在文本里。纯 Python 的 html2text 的办法是加一个空格：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>这样能渲染出加粗，但改动了韩语原文。助词变成了一个单独的词，在韩语里这是拼写错误。pyhtml2text 的做法是把标点移到标记外面：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>括号不再是粗体，但强调依然能正确解析，原文的每个字符也都保留了下来。日语和中文也是同样的道理：中文词与词之间没有空格，一段以括号结尾的加粗文字后面如果直接跟着&quot;的&quot;之类的字，也会碰到同样的问题。任何不在后续文字前加空格的语言都一样。</p>

<h3 class="relative group">读回来检查输出
    <div id="读回来检查输出" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%af%bb%e5%9b%9e%e6%9d%a5%e6%a3%80%e6%9f%a5%e8%be%93%e5%87%ba" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我不会因为一个转换器的单元测试都通过了就相信它，因为 Markdown 输出里的 bug 往往藏在没人想到要写测试的组合里。所以输出要读回来检查。</p>
<p>转换时，每个段落都会按 CommonMark 重新解析一次，确认强调读回来符合预期。如果不符合，就换另一种强调写法重写（用 <code>*</code> 代替 <code>_</code>，或者反过来）；两种都不行，就保留文字、链接和代码，去掉格式，保证一个字都不丢。测试套件则用另一个 Markdown 解析器做往返检查，把每一个可见的词、链接地址和图片地址同原始 HTML 逐一比较。因为没转义而被语法吞掉的文字、没解析出来的链接、多出来的 <code>*</code>，都会作为差异显示出来。它在真实页面和成千上万个随机文档上运行，出错时还会把文档缩减成最小的复现用例。</p>

<h2 class="relative group">上线
    <div id="上线" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%8a%e7%ba%bf" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>0.3.0 发布当天，我那个应用的 Markdown 内容协商就从纯 Python 的 html2text 换成了 pyhtml2text。在其中一个指南页面上，转换时间从 4.2 ms 降到了 0.36 ms，快了大约 12 倍。链接都是绝对地址，表格是真正的管道表格，韩语的加粗也不会再把助词分开。abi3 wheel 本来就覆盖了 x86_64 和 arm64 上的 Python 3.14，所以容器镜像不需要加任何新东西就能装上它。</p>

<h2 class="relative group">致谢，以及在哪里报告 bug
    <div id="致谢以及在哪里报告-bug" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%87%b4%e8%b0%a2%e4%bb%a5%e5%8f%8a%e5%9c%a8%e5%93%aa%e9%87%8c%e6%8a%a5%e5%91%8a-bug" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>纯文本渲染来自 Chris Emerson 的 rust-html2text，采用 MIT 许可证。pyhtml2text 在此之上加了它的一个 fork、一份打过补丁的 html5ever（MIT/Apache-2.0）以及 Markdown 转换器。这个包与这两个项目都没有关联，也没有得到它们的认可，所以<strong>有问题请报到 <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2text 的 issue 页面</a></strong>，不要报到上游。这些代码里的 bug 由我来修，上游维护者不应该收到关于他们拒绝接受的代码的报告。</p>
<p>如果你在用 Python 转换 HTML，特别是要给模型读的，不妨试试：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<p>源码在 <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a> 上，changelog 里列出了 0.1.0 到 0.3.0 的所有改动。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io 上的 Django 自动扩缩容：先压测出一台小机器的极限</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>我的两个 Django 应用都跑在 Fly.io 的 shared-cpu-1x 机器上：先把一台做快，用压测找到延迟崩掉的并发点，再把自动扩缩容的阈值设在它前面。</description>
      <content:encoded><![CDATA[<p>想在 Fly.io 上便宜地跑 Django，我的做法是：把一台 shared-cpu-1x 小机器尽量做快，压测到延迟崩掉为止，然后让 Fly 的自动扩缩容在这个点之前启动第二台机器。压测用的是按阶梯加压的 Locust（另一个应用用一个按并发档位逐级跑的小 Python 脚本），盯着 p95 延迟和错误率看。流量回落后 Fly 会自动停掉空闲机器，所以平时的成本就是每个应用一台小虚拟机。</p>
<p>我目前以独立开发者的身份运营着两个产品：一个是面向上门报价的手艺人的<strong>现场估价应用</strong>，另一个是带有信息发布和实时消息的<strong>集市兼社区应用</strong>。</p>
<p>产品完全不同，用户完全不同，但技术栈几乎一模一样——而且两者都只跑在 Fly.io 最便宜的档位 <strong>shared-cpu-1x 机器</strong>上。这个约束是刻意为之的。与其按峰值负载来配置服务器，我选择把一台小机器的性能榨到极限，精确测出它在哪里崩溃，然后让 Fly 的自动扩缩容器围绕这个实测极限来增减机器。这篇文章讲的就是让这一切成为可能的优化、阈值背后的压测数据，以及 Fly.io 上扩容和缩容到底是怎么运作的。</p>

<h2 class="relative group">理念：先测准一台机器，再做乘法
    <div id="理念先测准一台机器再做乘法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%90%86%e5%bf%b5%e5%85%88%e6%b5%8b%e5%87%86%e4%b8%80%e5%8f%b0%e6%9c%ba%e5%99%a8%e5%86%8d%e5%81%9a%e4%b9%98%e6%b3%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两个应用的扩缩容策略都是同样的三步：</p>
<ol>
<li>把一台小机器在合理范围内做到尽可能快。</li>
<li>压测到它崩溃为止，记下延迟塌陷时的并发水平。</li>
<li>配置 Fly 的自动扩缩容器，在临界点之前启动第二台机器，并在流量回落时停掉空闲机器。</li>
</ol>
<p>这样容量就随机器数量近似线性增长，而基线成本是每个应用一台小虚拟机。下面的所有内容，都是为这三步中的某一步服务的。</p>

<h2 class="relative group">让一台小机器变快
    <div id="让一台小机器变快" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%a9%e4%b8%80%e5%8f%b0%e5%b0%8f%e6%9c%ba%e5%99%a8%e5%8f%98%e5%bf%ab" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian：API 走 WSGI，WebSocket 走 uvloop ASGI
    <div id="granianapi-走-wsgiwebsocket-走-uvloop-asgi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#granianapi-%e8%b5%b0-wsgiwebsocket-%e8%b5%b0-uvloop-asgi" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两个后端都是 Django 6 + Django REST Framework。API 由基于 Rust 的服务器 <strong>Granian</strong> 提供服务，但跑的是 <strong>带阻塞线程池的 WSGI</strong>，而不是 ASGI：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">granian --interface wsgi myapp.wsgi:application <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers <span class="m">1</span> --blocking-threads <span class="m">16</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-max-rss <span class="m">1400</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-kill-timeout <span class="m">15</span> --respawn-failed-workers</span></span></code></pre></div></div>
<p>我最初用的是 <code>--interface asgi --loop uvloop</code>，事后看是选错了。这些应用里的每个 HTTP 视图都是同步（sync）的，而 Django 的 ASGI 处理器会以 <code>thread_sensitive</code> 方式跑同步视图——每个 worker（工作进程）一次只跑一个。于是 ASGI 把每个 worker 的并发请求实质压到约 1 个，却还把整套 asyncio/uvloop/Channels 栈白白装进内存。带 <code>--blocking-threads</code> 的 WSGI 则把这些同步视图放进一个真正的线程池：1 个 worker × 16 个线程 = 16 个并发的 I/O 密集型请求（数据库读取、S3 预签名），而常驻内存比异步栈更低。</p>
<p>为什么只用一个 worker？因为这台 VM 是 <code>shared-cpu-1x</code>——只有一个核。第二个 worker 在单核上没法并行跑 Python（GIL + 单核），却要白白多占一整个常驻的 Django 进程；Granian 会直接就此发出警告。一个 worker 配 16 个线程，就是单 vCPU 机器在 I/O 密集型工作上诚实的上限。<code>--workers-max-rss 1400</code> 会在 worker 越过内存上限时重启它，于是失控的 PDF 或图片任务会被回收，而不是把机器一路拖向 OOM。</p>
<p>WebSocket 被拆到了它自己的 Granian 进程里——一个用 uvloop 和 <code>--ws</code> 的 <strong>ASGI</strong> 应用，为长连接跑 1 个 worker。同步 API 完全不需要 ASGI 路径：当某个视图想往 socket 推送时，它会经由 Redis 调用 <code>async_to_sync(channel_layer.group_send)</code>，消息照样会跨进程送到 WebSocket 应用。两台服务器，各自跑自己真正擅长的接口。本地 Docker 也跑同样的 WSGI 线程池，因此测试时我的笔记本约等于一台生产机器。</p>

<h3 class="relative group">热路径交给 Rust
    <div id="热路径交给-rust" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%83%ad%e8%b7%af%e5%be%84%e4%ba%a4%e7%bb%99-rust" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>只要 Python 热路径上存在 Rust 实现的替代品，我就用它：</p>
<ul>
<li><strong>orjson</strong>（通过 <code>drf-orjson-renderer</code>）——所有 API 响应都经由 Rust 序列化，而不是标准库的 JSON 编码器。</li>
<li><strong>blurhash-rs</strong>——在上传时计算图片占位符。</li>
<li><strong>uuid-utils</strong>——Rust 实现的 UUID 生成。</li>
<li><strong>psycopg 3 (binary)</strong>——C 加速的 Postgres 驱动。</li>
</ul>
<p>这些都不改变架构，只是抬高了一颗 vCPU 在需要第二台机器之前所能承受的上限。</p>

<h3 class="relative group">消灭隐藏的延迟税
    <div id="消灭隐藏的延迟税" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b6%88%e7%81%ad%e9%9a%90%e8%97%8f%e7%9a%84%e5%bb%b6%e8%bf%9f%e7%a8%8e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两个代码库里最大的一次性能提升不是算法，而是连接复用。在 Granian 的线程池下，每个新线程都会重新构建一个 boto3 的 S3 客户端，而每个返回照片或 PDF 的接口都在支付这笔开销。我写了一个小小的自定义存储类，改为按进程缓存客户端之后：</p>
<blockquote><p>新线程上生成签名 URL 的耗时从 <strong>约 150ms 降到约 1ms</strong>。</p>
</blockquote><p>同样的教训也适用于数据库：Fly 的 Postgres 位于 PgBouncer 之后，所以应用设置 <code>conn_max_age=0</code>，把连接复用交给连接池，并用 5 秒的 <code>connect_timeout</code> 快速失败，避免 worker 挂在失效的 SSL 连接上。</p>

<h3 class="relative group">把缓存放在真正的瓶颈前面
    <div id="把缓存放在真正的瓶颈前面" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8a%8a%e7%bc%93%e5%ad%98%e6%94%be%e5%9c%a8%e7%9c%9f%e6%ad%a3%e7%9a%84%e7%93%b6%e9%a2%88%e5%89%8d%e9%9d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>压测社区市集时发现了一个值得记住的事实：<strong>天花板是 Postgres 连接数，而不是 CPU</strong>。浏览流量绝大多数是匿名的、用户之间完全相同，而在高负载下，这些相同的列表查询会在 CPU 闲置的同时把连接池打满。</p>
<p>解决办法是给匿名列表页加一层 Redis 缓存，用按命名空间的版本计数器做失效——一次写操作只需把一个计数器加一（O(1)，无需管理任何键），该资源的所有缓存页面立即失效。再用一个短 TTL 兜底。让浏览页面直接从 Redis 出去，真正的瓶颈就消失了，小机器可以把连接留给已登录的流量。</p>

<h3 class="relative group">不要消息代理的后台任务
    <div id="不要消息代理的后台任务" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%8d%e8%a6%81%e6%b6%88%e6%81%af%e4%bb%a3%e7%90%86%e7%9a%84%e5%90%8e%e5%8f%b0%e4%bb%bb%e5%8a%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两个应用都用 <strong>django-tasks-db</strong>——Django 的数据库后端任务框架——而不是 Celery。一个独立的 <code>db_worker</code> 进程轮询数据库来处理任务：图片处理、PDF 生成、机器翻译、提醒推送。没有消息代理，没有需要额外估算容量和监控的服务，API 进程也永远不会被重活阻塞。这个 worker 甚至绕过 PgBouncer 直连 Postgres，因为长时间运行的任务和事务级连接池合不来。</p>

<h3 class="relative group">内存：jemalloc 和交换空间缓冲
    <div id="内存jemalloc-和交换空间缓冲" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%86%85%e5%ad%98jemalloc-%e5%92%8c%e4%ba%a4%e6%8d%a2%e7%a9%ba%e9%97%b4%e7%bc%93%e5%86%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>图片和 PDF 处理造成的内存尖峰，是按稳态估算内存时无法预测的。两个便宜的缓解手段：在 Docker 镜像里预加载 <strong>jemalloc</strong> 来减少内存碎片，以及<strong>给每台机器都配交换空间</strong>（API 机器 2GB，WebSocket 机器 1GB），这样尖峰来临时进程会把内存换出到磁盘，而不是被 OOM 杀掉。交换空间不是性能特性——它是一份保险，让我能把内存配置一直保持在小规格。</p>

<h2 class="relative group">Django 压力测试：找到拐点
    <div id="django-压力测试找到拐点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#django-%e5%8e%8b%e5%8a%9b%e6%b5%8b%e8%af%95%e6%89%be%e5%88%b0%e6%8b%90%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>阈值只有在被实测过之后才站得住脚。每个项目都有自己的压测工具：</p>
<ul>
<li>估价应用有一个小的 Python 压测脚本，扫描各个并发级别并报告 RPS、p50/p95/p99 和错误率；它跑在预先生成的几百条样例数据上，让列表接口在接近真实的数据量下被查询。</li>
<li>社区市集用 <strong>Locust</strong> 以 30 秒为步长从 50 爬升到 500 用户来寻找饱和点，接口权重按真实的浏览为主的流量设定；另有一套更小的独立测试，以 p95 &lt; 2 秒为阈值，对生产环境从 5 爬升到 50 个虚拟用户。</li>
</ul>
<p>估价应用扫描的代表性数据（1 个 Granian worker × 16 个阻塞线程，开发机上的 Docker）：</p>
<table>
	<thead>
			<tr>
					<th>接口</th>
					<th>并发</th>
					<th>RPS</th>
					<th>p95</th>
					<th>错误率</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>重型列表</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>轻型列表</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>重型列表</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>而决定其他一切的那个数字，来自在<strong>单台 shared-cpu-1x 机器</strong>上对社区市集的压测。在旧的 ASGI 构建里，这曾是一堵硬墙：同步视图被实质串行化到大约<strong>3 个可用槽位</strong>，于是 p95 在约 5 个并发请求以内保持在 300ms 以下，随后当一切都排在 thread_sensitive 锁后面时，超过约 8–10 个并发就塌陷。把拐点往外推的，正是把 API 迁到 WSGI 线程池这件事——每台机器的真实并发槽位从约 3 个变成了 <strong>16 个</strong>，而这个新的上限就是整个自动扩缩容配置的依据。</p>

<h2 class="relative group">Fly.io 上的扩缩容是怎么运作的
    <div id="flyio-上的扩缩容是怎么运作的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#flyio-%e4%b8%8a%e7%9a%84%e6%89%a9%e7%bc%a9%e5%ae%b9%e6%98%af%e6%80%8e%e4%b9%88%e8%bf%90%e4%bd%9c%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 的模型很简单：你的应用是代理后面的一组相同机器，代理统计每台机器的负载。你只需要声明两个数字：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;requests&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">12</span>   <span class="c"># 超过就启动另一台机器</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">24</span>   <span class="c"># 超过就直接卸载负载而不是排队</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>**扩容：**当一台机器上的在途（in-flight）请求超过软上限（soft limit），代理会启动一台停止中的机器，并把新流量路由过去。WSGI 线程池让每台机器有 16 个真实并发槽位，所以软上限设为 12——约为池子的 75%，于是第二台机器会在线程还有余量时启动，而不是等用户已经在承受数秒延迟之后。硬上限 24（池子的 1.5 倍）是熔断器：短促的突发会在线程上排队，超过它，代理宁可卸载负载，也不让一台机器被压垮。（旧的 ASGI 构建只有约 3 个真实槽位，用的是 8/20；正是把池子拓宽，才让这两个数字都能往上调。）</p>
<p>**缩容：**流量回落时，Fly 自动停掉空闲机器，最低降到 <code>min_machines_running = 1</code>。停止的机器不产生计算费用，只收 rootfs 存储费，所以每个应用的基线成本就是字面意义上的一台小虚拟机，而突发容量随时待命。</p>
<p>我原本在这里写着，停止的机器&quot;冷启动不到一秒&quot;。这是错的，而且错得很贵。当我不再假设、真正去测量之后，一次冷启动要 <strong>25 到 33 秒</strong>，落到一台还在启动的机器上的请求，就得把这段时间整个等完。我一开始把 CDN 面板上 22% 的失败率归咎于这些冷启动，当时健康检查一直是绿的。后来才发现，那个数字是 CDN 自己的 Early Hints 探测请求在源站失败造成的，真实客户端一个 504 都没收到。不过冷启动本身确实存在。</p>
<p>解法不在上面的伸缩数字里，那些数字没问题。解法在于机器启动过程中做了什么，以及让 Fly 挂起机器而不是停止它们——那样唤醒就变成大约 3 秒的内存恢复。我单独写了一篇：<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零，于是每次重启都是一次 30 秒的故障</a> 讲了诊断过程和该改什么。如果你在 Fly 上跑着任何用了 <code>auto_stop_machines</code> 的东西，先读那篇，再来相信这一段。</p>
<p>一个微妙之处：并发类型是 <code>requests</code> 而不是 <code>connections</code>。现代客户端会一直保持 keep-alive 连接，所以按连接数统计时数字在真实负载下几乎不动，自动扩缩容永远不会触发。统计在途请求，才能追踪真实的工作量。</p>

<h3 class="relative group">WebSocket 在另一条轴上扩缩
    <div id="websocket-在另一条轴上扩缩" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#websocket-%e5%9c%a8%e5%8f%a6%e4%b8%80%e6%9d%a1%e8%bd%b4%e4%b8%8a%e6%89%a9%e7%bc%a9" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>社区市集把 WebSocket 服务器拆成了<strong>第二个 Fly 应用</strong>，与 API 共享同一个 Docker 镜像。API 应用用 WSGI 跑 1 个 worker × 16 个阻塞线程，按<em>请求</em>计数（软上限 12）；WebSocket 应用在一台 <strong>512MB</strong> 的机器上用 ASGI 配 uvloop 跑 1 个 worker，按<em>连接</em>计数：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>一条长连接维持起来几乎零成本，却会占用一个槽位好几个小时；而一个 HTTP 请求只占用槽位几毫秒——同一个扩缩容信号无法同时服务两者。拆开之后，每个机群都按对自己诚实的指标扩缩，而空闲的 WebSocket 容量只花 512MB，不是 2GB。在 Channels 这一侧，Redis 层以 <code>capacity: 1500</code>、消息 60 秒过期运行，并每 30 秒发送 socket keepalive——因为 Fly 的 Redis 代理会把 Channels 赖以工作的长时间 BRPOP 连接当作空闲连接掐掉。</p>

<h2 class="relative group">把计算卸载到边缘和客户端
    <div id="把计算卸载到边缘和客户端" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8a%8a%e8%ae%a1%e7%ae%97%e5%8d%b8%e8%bd%bd%e5%88%b0%e8%be%b9%e7%bc%98%e5%92%8c%e5%ae%a2%e6%88%b7%e7%ab%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最便宜的服务器工作，是服务器根本不用做的工作：</p>
<ul>
<li><strong>静态文件和媒体</strong>放在 Fly 的 S3 兼容对象存储上，私有媒体通过签名 URL 走 CDN 分发——图片的每一个字节都不经过 Django 机器。</li>
<li>**AI 推理跑在用户的手机上。**两个应用都内嵌了一个小型本地 LLM（量化的 4B 模型，约 2.7GB），直接在设备上运行。服务端推理成本为零，而且离线也能用。</li>
<li><strong>离线优先的移动端</strong>（设备上的 SQLite 加带重试的同步队列）意味着应用会把写操作攒批、容忍信号盲区，而不是每敲一个键就向 API 发一次琐碎请求。</li>
</ul>

<h2 class="relative group">其余的技术栈，简单说
    <div id="其余的技术栈简单说" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%b6%e4%bd%99%e7%9a%84%e6%8a%80%e6%9c%af%e6%a0%88%e7%ae%80%e5%8d%95%e8%af%b4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>认证用托管的身份服务加 WebAuthn 通行密钥（passkey），WebSocket 连接在协议升级完成前就完成认证。生产用 Postgres，开发用 SQLite。后台管理和审计记录用了几个现成的 Django 包。移动端是基于 Expo 的 React Native 和 TypeScript，配 Expo Router 和 Tailwind，OTA 更新由自托管的 Expo Updates 服务器下发，应用内订阅交给一个订阅管理服务。质量关卡：后端 pytest 和 mypy，客户端单元测试和端到端测试。</p>

<h2 class="relative group">值得你拿走的东西
    <div id="值得你拿走的东西" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%80%bc%e5%be%97%e4%bd%a0%e6%8b%bf%e8%b5%b0%e7%9a%84%e4%b8%9c%e8%a5%bf" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>可迁移的想法不是某个具体的库，而是这个循环本身：把一台便宜机器做到真正的快（Rust 热路径、连接复用、在真正的瓶颈前面加缓存、能卸给 CDN 和客户端的都卸掉）；压测到找出它到底在哪个并发数崩溃；把自动扩缩容的软上限设在那个拐点上，让空闲机器停机，把最小机群保持在 1。两个生产应用、实时功能、AI 助手——而平时的账单，只是这个平台上最便宜的几台虚拟机。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>一幅画的价值由什么决定？用算法预测艺术品拍卖价格</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-04-01-art-evaluation-algorithm/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-04-01-art-evaluation-algorithm/</guid>
      <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Machine Learning</category>
      <category>XGBoost</category>
      <category>Art Market</category>
      <category>Python</category>
      <category>Data Science</category>
      <description>艺术品拍卖价格由什么决定？复现一项 2024 年基于 34,200 笔拍卖成交的 XGBoost 研究后发现，市场定价的是艺术家，不是作品。这篇讲用到的特征、得出的结果，以及我围绕它们搭的艺术品估价平台。</description>
      <content:encoded><![CDATA[<p>一幅画在拍卖会上值多少钱，取决于什么？主要看艺术家，而不是画本身。2024 年一项针对 34,200 笔当代艺术拍卖成交的研究里，只看作品本身的模型，只能解释约 5% 的价格差异；基于艺术家职业经历、展览履历和过往成交记录的模型，能解释约 73%；再加上拍卖行的预售估价，就到了 92%。下面讲我是怎么复现这个结果的，以及它对用算法给艺术品估价意味着什么。</p>
<p>过去几周我在做两个相关的项目。第一个是复现 2024 年 <em>Scientific Reports</em> 上 Lee 等人的论文 &ldquo;Social signals predict contemporary art prices better than visual features, particularly in emerging markets&rdquo;，它用 590 位在世艺术家的 34,200 条拍卖记录训练了一个 XGBoost 模型。第二个是搭建一个艺术品平台，核心是一张连接艺术家、拥有者和拍卖记录的知识图谱。说白了，就是你想拿那种模型做点正经事时必须先铺好的数据管道。</p>
<p>两个项目最后绕回的都是同一个问题：一件艺术品能卖多少钱，真的可以预测吗？答案是可以，但靠看作品本身不行。管用的特征几乎全是社会性的。</p>

<h2 class="relative group">为什么艺术品难估价
    <div id="为什么艺术品难估价" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%89%ba%e6%9c%af%e5%93%81%e9%9a%be%e4%bc%b0%e4%bb%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>动手写代码之前，我花了几天读评估师和拍卖行到底怎么定价。要考虑的因素很多，而且大多没法干净地量化。来源——谁拥有过它、在哪展出过、被哪些图录收录。归属和真伪：经艺术家基金会确认的真品，比只是&quot;传为某人所作&quot;的作品贵好几个数量级。艺术家的声誉，由个展、双年展、博物馆收藏和批评界反响一点点垒起来。再加上品相和修复史、在艺术家作品体系里的稀缺程度，还有媒介——布面油画通常好过纸本，纸本又好过版画。被大博物馆收藏，等于盖上了一枚二级市场信得过的章。时机也算数：五年前的可比案例已经过时了。</p>
<p>拍卖行的专家还会谈&quot;情感价值&quot;：藏家的渴望、竞价大战的氛围、个人的共鸣。这种东西能把落槌价推到任何理性估算之外。你没法直接建模它，但可以借拍卖行的预估价间接捕到它的轮廓。</p>
<p>实务中占主导的估值方法是可比销售分析：找同一位艺术家相似作品的近期拍卖结果，再按差异调整。XGBoost 干的本质上就是这件事，只是规模大得多，用的特征也多到任何人类评估师都没法手工跟踪。</p>

<h2 class="relative group">论文的核心论点
    <div id="论文的核心论点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%ba%e6%96%87%e7%9a%84%e6%a0%b8%e5%bf%83%e8%ae%ba%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Lee 等人的论点很反直觉：作品的视觉内容几乎解释不了价格。他们只用视觉特征（颜色、构图、边缘密度、ResNet18 嵌入）的模型，R² 大约 0.055，比直接猜均值强不了多少。</p>
<p>而只用艺术家层面&quot;社会性&quot;特征（职业阶段、展览履历、过往拍卖价格、ArtFacts 排名）的模型能到 R² ≈ 0.73。再把拍卖行的预售估价加进特征，能升到 0.92。</p>
<p>换句话说，作品本身几乎无关紧要。要紧的是谁做的，以及市场对这个人已经说过什么。</p>
<p>论文的第二个论点是，这个差距在新兴市场还会进一步拉大。他们说的新兴市场，指美国、英国、法国、德国这四个既有核心之外的一切。在那些市场里，社会信号更管用，专家预估反而更不准，留给算法预测创造价值的空间也就更大。</p>

<h2 class="relative group">复现论文
    <div id="复现论文" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a4%8d%e7%8e%b0%e8%ae%ba%e6%96%87" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>任务本身很直接：以单人开发的 MVP 复现这篇论文。不上生产，没有实时数据管道。忠实的重新实现、原型级的代码，加一份写清结论的报告。</p>

<h3 class="relative group">数据
    <div id="数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>论文以补充材料的形式提供了清洗好的 CSV：</p>
<table>
	<thead>
			<tr>
					<th>文件</th>
					<th>行数</th>
					<th>内容</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Df_mloutfull.csv</code></td>
					<td>86,221</td>
					<td>原始数据集，500+ 列</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_up_to_2012.csv</code></td>
					<td>34,200</td>
					<td>主清洗数据集，1996–2012</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_old_market.csv</code></td>
					<td>29,853</td>
					<td>仅既有市场</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_new_market.csv</code></td>
					<td>4,346</td>
					<td>仅新兴市场</td>
			</tr>
			<tr>
					<td><code>transactions.csv</code></td>
					<td>114,283</td>
					<td>含图片链接的原始拍卖记录</td>
			</tr>
	</tbody>
</table>
<p>全部加起来大约 5 GB，轻松塞进内存。这个规模的数据集，XGBoost 在笔记本 CPU 上五分钟内就能训练完，核心模型根本不需要 GPU。这个体量的表格数据，梯度提升树的训练成本约等于零——要是这几年一直泡在神经网络的世界里，这件事很容易忘。</p>

<h3 class="relative group">特征
    <div id="特征" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%89%b9%e5%be%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>模型用 38 个特征，分三类。</p>
<p><strong>关于艺术家（30 个）：</strong></p>
<ul>
<li>人口属性：年龄、性别、教育程度、是否名校。</li>
<li>职业：ArtFacts 排名、个展、群展、双年展参与、获奖。</li>
<li>收藏：个人和公共收藏数量。</li>
<li>价格历史：艺术家最近 5 次和 10 次销售的均值、中位数、最高、最低。</li>
<li>面积调整后的价格历史（每平方英寸价格）。</li>
<li>地理：艺术家工作和居住地，编码为按国家的标志位。</li>
<li>匹配标志：作品的题材是否与艺术家典型题材一致？销售国家是否一致？</li>
</ul>
<p><strong>关于市场（8 个）：</strong></p>
<ul>
<li>拍卖行等级（1–4）。</li>
<li>当年该国家/地区的价格水平（最低、平均、中位、最高）。</li>
</ul>
<p><strong>关于作品本身（非视觉）：</strong></p>
<ul>
<li>宽度、高度、平方英寸面积。</li>
<li>媒介类别（绘画、版画、摄影、雕塑、其他）。</li>
</ul>
<p>注意这份清单里缺了什么：任何关于作品实际视觉内容的信息。那部分只在单独的消融实验里出现。</p>

<h3 class="relative group">训练/测试拆分
    <div id="训练测试拆分" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%ad%e7%bb%83%e6%b5%8b%e8%af%95%e6%8b%86%e5%88%86" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>按时间拆，不是随机拆。2011 年 5 月之前的全部进训练集（约 80%，约 27,360 条），之后的进测试集（约 20%，约 6,840 条）。随机拆分会通过艺术家的价格历史特征泄漏未来信息：把同一位艺术家的记录随机撒在训练集和测试集里，测试集的行就已经通过滚动窗口&quot;看过&quot;彼此的价格，R² 会被人为抬高。</p>
<p>这种事看穿了觉得理所当然，第一次搭的时候却很容易漏掉。</p>

<h3 class="relative group">模型
    <div id="模型" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a8%a1%e5%9e%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>XGBoost 回归，预测 <code>log10(price_usd)</code>。在留出的验证切片上搜索 <code>max_depth</code> 和 <code>learning_rate</code>。两个模型变体：</p>
<ol>
<li><strong>不用专家预估。</strong> 目标 R² ≈ 0.73。</li>
<li><strong>使用专家预估。</strong> 目标 R² ≈ 0.92。</li>
</ol>
<p>&ldquo;使用预估&quot;的变体把拍卖行预售的低估和高估作为额外特征。这两个数字单独拿出来用，就能达到 R² ≈ 0.90。社会特征叠上去再多 0.02。绝对值不大，但这 0.02 才是模型在专家已知信息之外真正做出的预测贡献。</p>

<h3 class="relative group">我瞄准的目标
    <div id="我瞄准的目标" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e7%9e%84%e5%87%86%e7%9a%84%e7%9b%ae%e6%a0%87" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我没打算超过论文。目标是在所有条件下把 R² 控制在他们数值的 ±0.03 之内。头条数字（仅元数据约 0.73，加预估约 0.92）的可复现性应该不错：XGBoost 固定种子后是确定性的，特征定义也清楚。我预计会漂移的地方有两处：视觉特征消融，PCA 维数或图像预处理的小差异都会让结果晃动；还有新兴市场那一刀，只有 4,346 条记录，噪声下限本来就高。</p>

<h2 class="relative group">为什么视觉特征几乎没用
    <div id="为什么视觉特征几乎没用" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a7%86%e8%a7%89%e7%89%b9%e5%be%81%e5%87%a0%e4%b9%8e%e6%b2%a1%e7%94%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是我觉得最有意思的结果。论文的视觉流水线为每张图片提取 8,971 个数字：</p>
<ul>
<li><strong>GIST 描述子</strong>（960 维）：整体空间布局。</li>
<li><strong>方向梯度直方图（HOG）</strong>（2,915 维）：边缘结构。</li>
<li><strong>颜色直方图</strong>（4,096 维）：调色板分解。</li>
<li><strong>ResNet18 特征</strong>（1,000 维）：高层级预训练嵌入。</li>
<li><strong>色彩饱和度</strong>（1 维）：鲜艳度标量。</li>
<li><strong>复杂度</strong>（1 维）：边缘密度标量。</li>
</ul>
<p>全部用 PCA 压缩后喂给 XGBoost，得到 R² ≈ 0.055。只用颜色是 0.056，边缘结构 0.029，神经网络嵌入 0.009，基本等于零。</p>
<p>干净利落的解释是：拍卖价格是在艺术家层面、而不是单件作品层面定下来的。同一位艺术家的两幅画，不管画布上是什么，都会卖出相近的价钱，因为被定价的是签名。</p>
<p>读过拍卖图录注释的人会立刻明白这为什么成立。那些文字几乎全花在来源、展览史和可比销售上，对作品本身几乎不着一笔。</p>
<p>如果认真对待这个 5%，结论就是作品的美学内容和它的市场价值几乎脱钩。市场在奖励的东西，不管是什么，都不在画布上。一个能在像素层面理解艺术的模型，预测价格反而会输给一个只认识艺术家简历的模型。</p>

<h3 class="relative group">第三阶段是有条件的
    <div id="第三阶段是有条件的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac%e4%b8%89%e9%98%b6%e6%ae%b5%e6%98%af%e6%9c%89%e6%9d%a1%e4%bb%b6%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>论文的图片链接列指向的是 2012 年还有效的 URL，到 2026 年大多已经死了。我写的计划在第 8 天就安排了这项风险检查：随机抽 100 个 URL，看有几个还活着。要是一半以上都没了，视觉阶段整个跳过，把原因写进报告。</p>
<p>反正视觉特征只解释约 5% 的价格方差。为了确认一个这么小的数字，去和链接腐烂搏斗两个星期，划不来。把死链的调查结果写进报告，继续往前走，才是正解。</p>

<h2 class="relative group">既有市场 vs 新兴市场
    <div id="既有市场-vs-新兴市场" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%97%a2%e6%9c%89%e5%b8%82%e5%9c%ba-vs-%e6%96%b0%e5%85%b4%e5%b8%82%e5%9c%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>论文为既有市场（美国、英国、法国、德国）和新兴市场（其余 19 个国家）分别训练模型。核心结果：</p>
<table>
	<thead>
			<tr>
					<th>特征集</th>
					<th>既有市场 R²</th>
					<th>新兴市场 R²</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>仅视觉</td>
					<td>0.053</td>
					<td>0.056</td>
			</tr>
			<tr>
					<td>元数据（社会信号）</td>
					<td>0.667</td>
					<td>0.750</td>
			</tr>
			<tr>
					<td>元数据 + 预估</td>
					<td>0.916</td>
					<td>0.859</td>
			</tr>
	</tbody>
</table>
<p>有两点跳出来。第一，社会信号在新兴市场更管用，而不是更不管用。我原以为既有市场数据更厚，社会特征的表现应该更好，结果正好相反。</p>
<p>第二，新兴市场的专家预估更不准——加入预估后的 0.916 对 0.859，差距就在这。在既有市场，算法预测得和背靠几十年可比数据的大拍卖行专家硬碰硬；在新兴市场，那位专家手里的数据薄得多，算法能补的缺口也就大得多。</p>
<p>真要把这种模型拿去商业化，能赚回饭钱的地方是新兴市场。</p>

<h2 class="relative group">该说清楚的局限
    <div id="该说清楚的局限" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%af%a5%e8%af%b4%e6%b8%85%e6%a5%9a%e7%9a%84%e5%b1%80%e9%99%90" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是论文复现，不是生产系统。它没有实时数据管道，模型用 1996–2012 的拍卖数据训练完就再也不更新了。没有部署，产出是几个 Notebook 和一份报告，不是预测 API。没有漂移检测，训练完的模型不知道市场什么时候变了。数据集是冻结的：加密艺术、NFT、新冠之后的市场变化，在训练数据里根本不存在。要是想预测韩国拍卖行的价格，还需要拍卖行专属特征，可能还得给韩国市场单独训一个模型——这些都没做。</p>
<p>模型反映的也只是历史模式。如果当代艺术市场在 2015 年前后发生过结构性变化（确实有人这么论证：艺博会崛起为一级市场的主要场地，新一代藏家入场），那 1996–2012 的规律未必还能外推。</p>

<h2 class="relative group">从论文到平台
    <div id="从论文到平台" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bb%8e%e8%ae%ba%e6%96%87%e5%88%b0%e5%b9%b3%e5%8f%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>读完论文，我看第二个项目的眼光变了。这个平台最初的定位是艺术展览的众筹市场：艺术家提前为即将举办的展览锁定资金，投资者则比拍卖市场通常允许的时点更早接触到新兴艺术家。</p>
<p>产品表层刻意做得简单。艺术家发布需要资金的展览（场地、日期、预计参展作品）。投资者按分级金额（$100、$500、$1k、$5k）支持特定展览，作品售出后分享收益。艺术家和投资者之间的大部分聊天由 AI agent 接手，双方都不必在线，对话也不会断。韩国画廊隔着 14 个小时时差和美国藏家谈事情的时候，这一点格外有用。</p>
<p>一旦&quot;资产是艺术家而不是作品&quot;这件事沉淀下来，数据模型也得跟着改。要抓住的是：每件作品被谁拥有过、在哪展出过、可比作品卖了多少钱、还有谁也收藏这位艺术家。所有权链条、机构认可、拍卖可比案例、藏家网络——这正是知识图谱的用武之地。</p>
<p>数据模型故意做得很小：艺术家、作品、拍卖记录、持有期，再加一份数据本身的来源日志，每条记录是从哪抓来的都能追回去。最后这一项听着像记账，但数据是爬来的，有了它这张图才信得过。</p>
<p>移动端把这一切渲染成一张力导向图。时间范围滑块可以在年份间拖动，看着网络一点点长出来：哪些藏家什么时候入场，哪些艺术家被哪家博物馆相中，哪些作品在 2008 年低谷期换了主人。</p>
<p>每类实体有自己的形状和颜色：艺术家是菱形，作品是圆角矩形，拍卖行是六边形，收藏者、经销商、博物馆、遗产管理这些持有方则是不同颜色的圆。</p>
<p>形状不是装饰。缩小到几百个节点的时候，不用等标签渲染，光看轮廓就知道那是什么实体。被一圈圆围住的菱形，是一位有藏家网络的艺术家；连着一堆菱形的六边形，是一家代理多位艺术家的拍卖行。市场的结构一眼就能读出来。</p>
<p>第二个标签页把同样的数据放到时间轴上：持有期是水平条，销售是点事件。同一张图，换个镜头。想看的是&quot;谁在什么时候持有了什么&quot;的先后顺序、而不是某一时刻的网络形状时，就用它。</p>
<p>这个平台本身不是价格预测工具，它是垫在预测工具下面的数据层。想给 Lee 等人那样的模型喂上鲜活的当前数据、而不是冻结在 2012 年的 CSV，需要的就是这套结构。</p>

<h2 class="relative group">把估价做成一个游戏
    <div id="把估价做成一个游戏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8a%8a%e4%bc%b0%e4%bb%b7%e5%81%9a%e6%88%90%e4%b8%80%e4%b8%aa%e6%b8%b8%e6%88%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我还没动手做、却最感兴趣的部分，是把艺术品估价变成游戏。</p>
<p>Lee 等人的论文把拍卖行预售估价当特征用，R² 因此大涨。那些预估之所以值钱，是因为它们是受过训练的专家的聚合判断——这些人看过成千上万件可比作品，而且对结果有切身利害。但拍卖行专家是一种又窄又贵的资源：全球就几百位，只做具体的委托案，够不着那条还没人委托的艺术家长尾。</p>
<p>我反复回到的问题是：能不能从一个更宽、更便宜的判断池里，合成出一个可比的信号？</p>
<p>粗略的设计：</p>
<ul>
<li>给用户看一件作品：图片、尺寸、年份、艺术家名字、简短履历。</li>
<li>问他们这件作品拍卖会卖多少钱。</li>
<li>让他们选一个价格区间，或者直接猜落槌价。</li>
<li>提交之后揭晓真实成交价。</li>
<li>用类似 Brier 分数的方式，随时间给他们打准确度评级。</li>
<li>提供排行榜、每日连胜、手感火热徽章。</li>
<li>用历史准确度给每位用户未来的预测加权。</li>
</ul>
<p>本质上这是一个艺术品价格的预测市场，套着猜价游戏的壳。循环和猜地理位置的游戏、在线国际象棋的谜题评分一样：可重复、可打分、有反馈，能练出真本事。猜得准的人在排行榜上爬升，他们未来的猜测在聚合信号里的权重也更大。</p>
<p>真正的价值在模型那一侧。这些猜测的加权聚合，可以和社会信号并排喂给 XGBoost。如果群体的准确度加权中位数和落槌价相关性够好，你就为那些没有真实预估的作品造出了近似的&quot;合成拍卖行预估&rdquo;。而这恰恰补在 Lee 等人的模型最缺帮助的位置——艺术家长尾。</p>
<p>冷启动的角度也说得通。模型在价格历史丰厚的艺术家身上训练得很好，在只有三笔销售的艺术家身上一塌糊涂。三笔销售加一千个群体猜测，至少有东西可用了。众包估价，就是在给底层数据覆盖不到的情形制造训练信号。</p>
<p>游戏化在这里之所以关键，是因为让群体可靠的唯一办法，是把参与做得足够好玩，让人反复来玩。一次性的问卷只能从随手点过的人那里收到噪声。每日连胜加排行榜加校准徽章，能让同一个人在一年里认认真真做出 500 次预测。数量，再加上自我筛选（猜得准的留下来，猜不准的觉得无聊走人），才是把噪声变成信号的方式。</p>
<p>动手之前还有几个设计问题要想清楚。给用户看多少东西——只有图片和履历，还是连可比销售也给？上下文越多猜测越有依据，但过了某个点，用户就只是在复读你已经展示的内容。价格反馈给到什么粒度？揭示精确落槌价、一个区间，还是只告诉他们有没有落在 ±20% 之内？太精细会训练出锚定具体数字的用户，太模糊又没法校准。还有刷榜问题：玩家会扎堆猜自己认识的艺术家，猜 Banksy 基本是常识题而不是技能题。解法大概是像语言学习 App 选课那样强制随机抽样，再给隐去艺术家姓名的&quot;盲猜&quot;轮单设一个榜。最后是事后偏见，躲不掉——用户一旦知道某件 Basquiat 卖了 1.1 亿美元，就再也无法当作不知道。让这个循环保持诚实的，是源源不断的新作品供给，而这种供给每周是有限的。</p>
<p>游戏化的框架还把平台的两半拧在了一起。众筹一侧奖励的是展览财务表现好时的投资者；猜价一侧奖励任何会准确叫价的人，不管掏没掏钱。两者是同一个想法的两种形态：把群体的集体判断变成可交易的信号——一个交易资本，一个交易注意力。真正练出准头的玩家，最终可以带着可验证的叫价准确度记录，&ldquo;毕业&quot;到投资者那一侧。这个过滤器，比&quot;认识对的画廊主&quot;强多了。</p>

<h2 class="relative group">我带走的几件事
    <div id="我带走的几件事" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e5%b8%a6%e8%b5%b0%e7%9a%84%e5%87%a0%e4%bb%b6%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>预售估价非常厉害。拍卖行常因定价不透明挨骂，但他们的预估单凭自己就能捕捉接近 90% 的价格方差。专家在做什么且不论，做得是真好。可与此同时，作品本身在统计上几乎无关紧要——如果你是出于审美喜欢艺术，这个结论并不好受。市场定价的不是你眼睛看到的东西，而是别人已经为相邻作品掏过的钱。</p>
<p>建模层面，有两个习惯物有所值。按时间拆分是评估价格模型唯一诚实的方式，随机拆分给出的漂亮 R² 到了真实生产环境就露馅。梯度提升树在这里依然是正确的工具：论文里 XGBoost 赢了所有神经网络基线，这种规模、这种工程化程度的表格数据，没有理由去够 transformer。</p>
<p>商业上的观察也有两条。有意思的机会在新兴市场，那里专家定价最薄，算法预测能补的缺口最大。以及，群体是一个被低估的数据来源。Lee 等人模型里最大的单一特征就是拍卖行预估；如果校准过的群体能为艺术家长尾产出可比的信号，那是实打实的机会——既是模型输入，本身也能是个产品。</p>
<p>两个项目最后比我计划的更互补。论文复现教会我什么在预测价格，平台工作教会我要用上这些知识需要什么样的数据结构，游戏化的想法则把两头接起来：知识图谱供给作品和历史销售，猜价游戏产出群体推导的预估特征，模型把这个特征和社会信号一起消化，为那些原本对它不可见的艺术家给出预测。</p>
<p>这些都还没做完。论文复现是一个附带书面报告、能跑的原型；平台是一个填了种子数据的演示；众包猜价游戏眼下只存在于你刚读完的这份设计笔记里。但贯穿其中的想法足够一致，正确的形状在我看来已经相当清楚：给艺术家定价，而不是给作品定价；聚合群体，而不是只依赖专家；把估价当成市场共同产出的东西，而不是专家自上而下交下来的结论。</p>
<p>有时间的话，下一个要做的就是这个版本。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-04-01-art-evaluation-algorithm/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
