<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>穗积的宇宙船</title>
        <link>https://blog.huanment.top/</link>
        <description>一个普普通通的全栈程序员穗积将与大家在这个宇宙船里分享一些开发经验和生活点滴。大家共同进步！穗积宇宙船将承载我们的梦想与希望扬帆起航！
    feedId:1216885715350978560+userId:1171845265145856000
    </description>
        <lastBuildDate>Thu, 13 Aug 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-CN</language>
        <image>
            <title>穗积的宇宙船</title>
            <url>https://blog.huanment.top/images/avatar.avif</url>
            <link>https://blog.huanment.top/</link>
        </image>
        <copyright>2026 穗积</copyright>
        <item>
            <title><![CDATA[从 Prompt 到 Harness：Agent 是如何一步步学会“自我约束”的]]></title>
            <link>https://blog.huanment.top/posts/harness-roading</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/harness-roading</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[从 Prompt、Reasoning 到 Function Calling、MCP、Skills，再到代码测试、E2E、世界模型与自我进化 Harness，梳理 AI Agent 的发展脉络。探讨 Agent 如何通过约束、工具与反复验证，让概率性的语言生成逐渐变成可靠的任务执行系统。]]></description>
            <content:encoded><![CDATA[<h1>从 Prompt 到 Harness：Agent 是如何一步步学会“自我约束”的</h1>
<p>过去几年，大语言模型的发展有一个非常容易被忽略的变化：</p>
<p><strong>我们真正进步的，并不只是模型本身越来越聪明，而是我们越来越擅长给模型建立“约束”。</strong></p>
<p>最早，我们相信一个足够好的 Prompt 就可以让 AI 按照我们的要求工作。</p>
<p>后来，我们发现仅靠 Prompt 不够，于是给模型增加了思考过程；再后来，我们给它工具、函数调用、MCP、Skills、记忆、代码执行环境、测试系统、浏览器和各种反馈回路。</p>
<p>最终，我们逐渐得到今天所谓的 <strong>Agent</strong>。</p>
<p>但如果把这些技术抽象到一起，会发现它们背后其实存在一个非常统一的思想：</p>
<blockquote>
<p><strong>Agent 并不是简单地让模型“自己做事情”，而是在模型之外建立一个 Harness，不断规定它应该如何行动、如何获取信息、如何验证自己的行为，以及失败以后如何重新行动。</strong></p>
</blockquote>
<p>换句话说：</p>
<p><strong>Agent = Model + Harness。</strong></p>
<p>而 Harness 的核心任务，就是把一个概率性的语言生成器，变成一个能够在约束环境中不断行动、验证和修正的执行系统。</p>
<p>这可能才是 Agent 这几年真正的发展主线。</p>
<h2>一、最早的 Agent：其实只是一段 Prompt</h2>
<p>最初的大语言模型并没有今天意义上的 Agent。</p>
<p>我们通常只是把一段指令放进 Prompt：</p>
<blockquote>
<p>你是一名专业程序员，请帮我修复这个 Bug。
请仔细分析问题。
不要编造 API。
如果不确定，请告诉我。
输出最终代码。</p>
</blockquote>
<p>看起来已经非常像一个 Agent 了。</p>
<p>但实际上，这里面几乎没有任何真正的“控制”。</p>
<p>模型依然只有一个动作：</p>
<p><strong>生成下一段 Token。</strong></p>
<p>它没有真正的文件系统，没有真正的数据库，没有编译器，没有浏览器，也没有一个外部机制能够判断它说的到底对不对。</p>
<p>于是出现了一个非常核心的问题：</p>
<blockquote>
<p>模型知道“应该正确”，并不意味着它能够做到正确</p>
</blockquote>
<p>例如：</p>
<pre><code class="language-text">请使用 Next.js 16 最新 API。
</code></pre>
<p>模型可能非常自信地写出一个实际上属于 Next.js 13 的 API。</p>
<p>因为模型真正拥有的知识并不是：</p>
<blockquote>
<p>“Next.js 16 的 API 是什么？”</p>
</blockquote>
<p>而更接近：</p>
<blockquote>
<p>“根据我见过的文本，什么样的 Token 序列最可能出现在这里？”</p>
</blockquote>
<p>这就是幻觉问题最底层的原因之一。</p>
<p><strong>Prompt 可以告诉模型规则，却不能保证模型遵守规则。</strong></p>
<p>所以早期所谓的 Prompt Engineering，本质上是在研究：</p>
<blockquote>
<p><strong>如何用自然语言最大程度地改变模型的概率分布。</strong></p>
</blockquote>
<p>这非常有用，但它天然存在上限。</p>
<h2>二、第二阶段：让模型“先想一遍”</h2>
<p>接下来出现了一个极其重要的变化：</p>
<p><strong>不要让模型直接回答。</strong></p>
<p>而是：</p>
<pre><code class="language-text">问题
 ↓
思考
 ↓
检查
 ↓
答案
</code></pre>
<p>这就是 Reasoning Model 的核心思想。</p>
<p>DeepSeek-R1 是这个阶段非常具有代表性的公开案例。DeepSeek 在 R1 的后训练中大量使用强化学习，并强调推理过程中存在大量反思与验证；官方 API 也将 reasoning content 与最终 content 分开。</p>
<p>这时候模型的行为从：</p>
<pre><code class="language-text">Input → Output
</code></pre>
<p>变成：</p>
<pre><code class="language-text">Input
  ↓
Reasoning
  ↓
Output
</code></pre>
<p>看起来只是多了一段文本。</p>
<p>但从 Harness 的角度来看，它实际上引入了一个非常重要的概念：</p>
<blockquote>
<p><strong>中间状态。</strong></p>
</blockquote>
<p>模型不再必须一步从问题跳到答案。</p>
<p>它可以先提出假设：</p>
<pre><code class="language-text">我认为答案可能是 A。
</code></pre>
<p>然后重新检查：</p>
<pre><code class="language-text">如果答案是 A，那么条件 X 是否成立？
</code></pre>
<p>再发现：</p>
<pre><code class="language-text">等等，X 不成立。
</code></pre>
<p>最后：</p>
<pre><code class="language-text">所以 A 不正确，应该重新推导。
</code></pre>
<p>这就是所谓的 <strong>self-verification / self-correction</strong> 的雏形。</p>
<p>DeepSeek 官方文档也明确说明，思考模式是在输出最终答案之前先生成推理过程，以提升最终答案的准确性。</p>
<h2>三、但“思考”依然不是验证</h2>
<p>这里有一个非常重要的区别。</p>
<p>很多人会把：</p>
<blockquote>
<p>思考得更多</p>
</blockquote>
<p>理解成：</p>
<blockquote>
<p>验证得更多。</p>
</blockquote>
<p>实际上两者完全不同。</p>
<p>如果让模型：</p>
<pre><code class="language-text">请仔细思考这个问题。
</code></pre>
<p>它可能只是生成了更多 Token。</p>
<p>甚至可能出现：</p>
<pre><code class="language-text">错误假设
 ↓
非常详细的推理
 ↓
非常自信的错误答案
</code></pre>
<p>因此真正重要的不是：</p>
<p><strong>模型有没有思考。</strong></p>
<p>而是：</p>
<p><strong>模型有没有获得检查自己答案的能力。</strong></p>
<p>这也是 Harness 开始真正发挥作用的地方。</p>
<h2>四、第三阶段：Function Calling——给模型一双“手”</h2>
<p>当模型开始具备 Reasoning 之后，下一个问题就出现了：</p>
<blockquote>
<p>如果模型认为自己需要验证某件事情，它应该去哪里验证？</p>
</blockquote>
<p>于是我们开始给模型工具。</p>
<p>例如：</p>
<pre><code class="language-text">模型
 ↓
我要计算 12345 × 67890
 ↓
调用 calculator
 ↓
得到结果
 ↓
继续推理
</code></pre>
<p>或者：</p>
<pre><code class="language-text">模型
 ↓
我要查询数据库
 ↓
调用 SQL Tool
 ↓
得到真实数据
 ↓
继续推理
</code></pre>
<p>或者：</p>
<pre><code class="language-text">模型
 ↓
我要检查代码
 ↓
调用 TypeScript Compiler
 ↓
得到类型错误
 ↓
修改代码
</code></pre>
<p>这一步非常关键。</p>
<p>因为模型第一次拥有了：</p>
<blockquote>
<p><strong>语言世界之外的反馈。</strong></p>
</blockquote>
<p>之前模型只能说：</p>
<blockquote>
<p>“我觉得这个 API 存在。”</p>
</blockquote>
<p>现在它可以：</p>
<pre><code class="language-text">import ...
↓
TypeScript
↓
error TS...
</code></pre>
<p>它不需要“相信自己”。</p>
<p><strong>现实环境会告诉它答案。</strong></p>
<p>于是 Agent 开始从：</p>
<pre><code class="language-text">Think → Answer
</code></pre>
<p>变成：</p>
<pre><code class="language-text">Think
 ↓
Act
 ↓
Observe
 ↓
Think
 ↓
Act
 ↓
Observe
 ↓
Answer
</code></pre>
<p>这已经非常接近现代 Agent 的基本循环。</p>
<h2>五、MCP：把“工具”变成了标准化接口</h2>
<p>Function Calling 解决了：</p>
<blockquote>
<p>模型可以调用工具。</p>
</blockquote>
<p>但还有一个问题：</p>
<blockquote>
<p><strong>工具从哪里来？</strong></p>
</blockquote>
<p>如果每个 Agent 都需要自己定义：</p>
<pre><code class="language-text">github_search()
github_issue()
github_pr()
notion_search()
database_query()
browser_open()
...
</code></pre>
<p>生态很快就会碎片化。</p>
<p>MCP（Model Context Protocol）解决的正是这一层问题。</p>
<p>它把 AI 应用与外部数据源、工具以及工作流之间的连接标准化。官方定义中，MCP 可以让 AI 应用连接本地文件、数据库、搜索引擎、计算器等外部能力。</p>
<p>于是 Agent 不再只是：</p>
<blockquote>
<p>“我知道很多东西。”</p>
</blockquote>
<p>而变成：</p>
<blockquote>
<p>“我可以去很多地方获取事实。”</p>
</blockquote>
<p>例如：</p>
<pre><code class="language-text">LLM
 │
 ├── MCP → GitHub
 │
 ├── MCP → Database
 │
 ├── MCP → Browser
 │
 ├── MCP → Figma
 │
 └── MCP → Documentation
</code></pre>
<p>这实际上是把模型从一个<strong>封闭的语言系统</strong>，变成了一个<strong>开放的执行系统</strong>。</p>
<h2>六、Skills：不仅告诉 Agent“有什么工具”，还告诉它“什么时候、怎么用”</h2>
<p>MCP 更像是：</p>
<blockquote>
<p>这里有一把锤子。</p>
</blockquote>
<p>而 Skill 更接近：</p>
<blockquote>
<p>当你遇到钉子的时候，应该拿这把锤子，并按照下面的流程操作。</p>
</blockquote>
<p>这就是 Skills 的重要意义。</p>
<p>一个 Skill 往往包含：</p>
<pre><code class="language-text">什么时候触发
↓
需要读取什么上下文
↓
应该使用什么工具
↓
按照什么步骤执行
↓
哪些事情禁止做
↓
完成后如何验证
</code></pre>
<p>因此 Skill 本质上已经不是简单的知识库。</p>
<p>它更像是：</p>
<blockquote>
<p><strong>一份可以被模型执行的行为规范。</strong></p>
</blockquote>
<p>这也是为什么现在越来越多 Agent 系统开始出现：</p>
<pre><code class="language-text">AGENTS.md
SKILL.md
CLAUDE.md
rules/
instructions/
workflows/
</code></pre>
<p>这些东西看起来都是 Markdown。</p>
<p>但它们真正改变的并不是模型的知识。</p>
<p>而是：</p>
<p><strong>模型的行为轨迹。</strong></p>
<p>近期关于 Skill-Use 的研究甚至把 Skill 使用拆成了 Trigger、Compliance 和 Boundary 三个部分：模型是否正确触发 Skill、是否遵守流程，以及是否避免执行禁止操作。实验结果也表明，Skill 的效果高度依赖 Harness，而不是模型本身的固定能力。</p>
<p>这其实非常能说明问题：</p>
<blockquote>
<p><strong>一个模型有多强，和一个 Agent 有多可靠，是两件不同的事情。</strong></p>
</blockquote>
<h2>七、真正的转折点：不要让 AI 判断自己“做对了没有”</h2>
<p>到这里，我们已经拥有：</p>
<pre><code class="language-text">Prompt
Reasoning
Tools
MCP
Skills
</code></pre>
<p>但仍然存在一个致命问题：</p>
<blockquote>
<p><strong>谁来判断 Agent 做得对不对？</strong></p>
</blockquote>
<p>如果最后还是：</p>
<pre><code class="language-text">AI：
“我检查过了，代码没问题。”
</code></pre>
<p>那么实际上什么都没有改变。</p>
<p>因为：</p>
<p><strong>让模型自己评价自己的答案，依然属于模型内部闭环。</strong></p>
<p>真正可靠的 Harness，需要把验证过程尽可能放到模型之外。</p>
<p>于是出现了：</p>
<pre><code class="language-text">Agent
 ↓
生成代码
 ↓
TypeScript
 ↓
ESLint
 ↓
Unit Test
 ↓
E2E Test
 ↓
Browser
 ↓
真实环境
</code></pre>
<p>这就是现代 Coding Agent 非常重要的思想：</p>
<blockquote>
<p><strong>不要让 AI 直接解决问题，让 AI 生成一个可以被机器验证的解决方案。</strong></p>
</blockquote>
<h2>八、代码为什么特别适合成为 AI 的 Harness？</h2>
<p>因为代码有一个非常特殊的属性：</p>
<p><strong>它可以被机器验证。</strong></p>
<p>例如 AI 说：</p>
<blockquote>
<p>我已经把类型修好了。</p>
</blockquote>
<p>不需要相信它。</p>
<p>直接：</p>
<pre><code class="language-bash">tsc --noEmit
</code></pre>
<p>AI 说：</p>
<blockquote>
<p>页面应该可以正常运行。</p>
</blockquote>
<p>不需要相信它。</p>
<p>直接：</p>
<pre><code class="language-bash">pnpm build
</code></pre>
<p>AI 说：</p>
<blockquote>
<p>登录流程没有问题。</p>
</blockquote>
<p>不需要相信它。</p>
<p>直接：</p>
<pre><code class="language-bash">playwright test
</code></pre>
<p>于是我们获得了一个非常漂亮的反馈循环：</p>
<pre><code class="language-text">┌───────────────┐
│      LLM      │
└───────┬───────┘
        │
        ▼
   修改代码
        │
        ▼
┌───────────────┐
│ Type Checker  │
└───────┬───────┘
        │
     Fail?
      /   \
    Yes    No
    │       │
    ▼       ▼
  修复    E2E Test
            │
         Fail?
          /   \
        Yes    No
        │       │
        ▼       ▼
      修复     Done
</code></pre>
<p>这时候 Agent 的可靠性不再完全依赖：</p>
<blockquote>
<p>“模型有多聪明。”</p>
</blockquote>
<p>而开始依赖：</p>
<blockquote>
<p><strong>“我们能不能建立一个足够强的反馈系统。”</strong></p>
</blockquote>
<h2>九、AI Workflow 的本质：把“回答问题”变成“通过测试”</h2>
<p>这也是为什么现代 AI Workflow 越来越像软件工程。</p>
<p>传统 AI：</p>
<pre><code class="language-text">Question
 ↓
LLM
 ↓
Answer
</code></pre>
<p>现代 Agent：</p>
<pre><code class="language-text">Goal
 ↓
Plan
 ↓
Generate
 ↓
Execute
 ↓
Observe
 ↓
Validate
 ↓
Repair
 ↓
Validate
 ↓
...
 ↓
Success
</code></pre>
<p>这里最重要的不是 Plan。</p>
<p>甚至也不是 Generate。</p>
<p>而是：</p>
<h2>Validate</h2>
<p>因为只要存在一个可靠的验证器，Agent 就拥有了一个明确的优化方向：</p>
<blockquote>
<p><strong>继续行动，直到通过验证。</strong></p>
</blockquote>
<p>这和传统软件工程中的测试驱动开发非常接近。</p>
<p>不是：</p>
<blockquote>
<p>“我觉得代码应该没问题。”</p>
</blockquote>
<p>而是：</p>
<blockquote>
<p>“测试通过了，所以在当前测试覆盖范围内，它满足了这些可验证条件。”</p>
</blockquote>
<p>这也是 Harness 的核心：</p>
<p><strong>把模糊的自然语言目标转换成可验证的状态。</strong></p>
<h2>十、Harness 可以被理解成“模型的现实世界”</h2>
<p>如果把 LLM 看成一个大脑，那么 Harness 就非常像：</p>
<pre><code class="language-text">大脑
 +
眼睛
 +
耳朵
 +
手
 +
工具
 +
记忆
 +
环境
 +
规则
 +
反馈
</code></pre>
<p>一个裸 LLM 只能预测：</p>
<pre><code class="language-text">接下来应该说什么？
</code></pre>
<p>而一个 Agent Harness 让它面对：</p>
<pre><code class="language-text">接下来应该做什么？
↓
做完以后发生了什么？
↓
结果符合预期吗？
↓
如果不符合，应该怎么办？
</code></pre>
<p>所以：</p>
<blockquote>
<p><strong>Agent 的本质不是让模型拥有“自主意识”，而是给模型建立一个可行动、可观察、可反馈的闭环。</strong></p>
</blockquote>
<h2>十一、Next.js 的 AGENTS.md：一个非常有代表性的最新案例</h2>
<p>这个趋势甚至已经开始反过来改变框架本身。</p>
<p>2026 年的 Next.js 已经把 AI Agent 当成了一等使用者。</p>
<p>Next.js 官方现在会将与安装版本匹配的文档直接放进：</p>
<pre><code class="language-text">node_modules/next/dist/docs/
</code></pre>
<p>然后通过项目根目录的 <code>AGENTS.md</code> 告诉 Agent：</p>
<pre><code class="language-text">Before any Next.js work, find and read the relevant doc in
node_modules/next/dist/docs/.

Your training data is outdated —
the docs are the source of truth.
</code></pre>
<p>也就是说：</p>
<p><strong>不要相信模型训练时记住的 Next.js。</strong></p>
<p>而应该：</p>
<pre><code class="language-text">当前项目
 ↓
当前 package.json
 ↓
当前安装的 Next.js
 ↓
当前版本对应的本地文档
 ↓
Agent
</code></pre>
<p>Next.js 官方文档明确解释了这一设计：安装 <code>next</code> 时会将版本匹配的文档放在 <code>node_modules/next/dist/docs/</code>，而 <code>AGENTS.md</code> 的作用就是要求 Agent 在写代码之前读取这些文档。</p>
<p>这其实是一个非常漂亮的 Harness。</p>
<p>因为它解决的是 Agent 最常见的问题之一：</p>
<blockquote>
<p><strong>模型知道 Next.js，但不知道你现在使用的是哪个 Next.js。</strong></p>
</blockquote>
<p>更有意思的是，Next.js 团队的评估显示，这种“始终可用的版本匹配上下文”在其评测中优于依赖 Agent 自己决定是否检索 Skill 的方案。</p>
<p>这说明了一个很重要的 Harness 原则：</p>
<blockquote>
<p><strong>不要把关键验证交给模型自己决定是否进行。</strong></p>
</blockquote>
<p>如果“读取文档”非常重要，就应该把它设计成流程的一部分。</p>
<h2>十二、甚至“PUA Agent”也可以从 Harness 的角度理解</h2>
<p>所谓 PUA，并不应该简单理解成“骂 Agent”。</p>
<p>真正值得关注的是它背后的控制思想：</p>
<pre><code class="language-text">你说完成了？
↓
证据呢？
↓
运行测试。
↓
失败了？
↓
继续修。
↓
再测试。
↓
还有问题？
↓
继续。
</code></pre>
<p>它把 Agent 从：</p>
<blockquote>
<p>“完成任务”</p>
</blockquote>
<p>变成：</p>
<blockquote>
<p><strong>“证明任务已经完成”。</strong></p>
</blockquote>
<p>这两个目标完全不同。</p>
<p>前者优化的是：</p>
<pre><code class="language-text">生成一个看起来像完成了的答案
</code></pre>
<p>后者优化的是：</p>
<pre><code class="language-text">产生一个能够通过验证的结果
</code></pre>
<p>这就是 Harness 思维。</p>
<h2>十三、世界模型：Harness 开始从“文字规则”走向“物理反馈”</h2>
<p>如果继续沿着这条路线发展，会出现一个更加有趣的方向：</p>
<p><strong>World Model。</strong></p>
<p>在文本世界里：</p>
<pre><code class="language-text">Agent
 ↓
文字
 ↓
文字反馈
</code></pre>
<p>而在物理世界：</p>
<pre><code class="language-text">Agent
 ↓
动作
 ↓
物理世界
 ↓
状态变化
 ↓
传感器
 ↓
反馈
</code></pre>
<p>例如一个模型学习机器人控制。</p>
<p>它可能预测：</p>
<pre><code class="language-text">如果向前移动 10cm，
物体应该移动到这里。
</code></pre>
<p>然后真正执行。</p>
<p>如果现实世界告诉它：</p>
<pre><code class="language-text">物体没有移动。
</code></pre>
<p>那么这个错误就是一个非常强的监督信号。</p>
<p>它不能靠一句：</p>
<blockquote>
<p>“我觉得应该移动。”</p>
</blockquote>
<p>来覆盖现实。</p>
<p><strong>物理世界本身就是 Validator。</strong></p>
<p>这就是为什么世界模型、机器人、模拟器、游戏环境会成为 AI 发展的重要方向。</p>
<h2>十四、为什么游戏特别适合训练 Agent？</h2>
<p>因为游戏天然提供了：</p>
<pre><code class="language-text">状态 State
动作 Action
奖励 Reward
失败 Failure
目标 Goal
环境 Environment
</code></pre>
<p>这几乎就是一个完美的 Agent Harness。</p>
<p>例如：</p>
<pre><code class="language-text">Agent：
我要通过这个关卡。

Game Engine：
你可以选择：
↑
↓
←
→
攻击
跳跃

Agent：
跳跃。

Game Engine：
失败。

Agent：
重新规划。

Agent：
攻击 + 跳跃。

Game Engine：
成功。
Reward = +1
</code></pre>
<p>这和现实世界的 Agent Loop 非常相似：</p>
<pre><code class="language-text">Observe
 ↓
Think
 ↓
Act
 ↓
Observe
 ↓
Evaluate
 ↓
Retry
</code></pre>
<p>只不过游戏引擎提供了一个成本低、反馈快、状态明确的世界。</p>
<p>因此游戏并不仅仅是 AI 的娱乐应用。</p>
<p>它本质上是：</p>
<blockquote>
<p><strong>一个廉价、可重复、可自动评分的世界模型实验场。</strong></p>
</blockquote>
<h2>十五、Remotion 也是一种很有意思的 Harness</h2>
<p>再看一个看起来完全不同的例子：</p>
<p><strong>AI + Remotion 视频生成。</strong></p>
<p>如果让 AI：</p>
<blockquote>
<p>“帮我做一个科技感视频。”</p>
</blockquote>
<p>那么结果很难验证。</p>
<p>但是如果把任务变成：</p>
<pre><code class="language-text">AI
 ↓
生成 React / Remotion Code
 ↓
TypeScript
 ↓
Render
 ↓
生成视频
 ↓
检查视频帧
 ↓
发现问题
 ↓
修改 Code
 ↓
重新 Render
</code></pre>
<p>事情就完全不同了。</p>
<p>AI 不再直接“描述一个视频”。</p>
<p>它生成：</p>
<blockquote>
<p><strong>一个可以被渲染器执行的程序。</strong></p>
</blockquote>
<p>而 Render Engine 就变成了 Validator。</p>
<p>于是：</p>
<pre><code class="language-text">Natural Language Goal
        ↓
       LLM
        ↓
   Program / Code
        ↓
    Type Checker
        ↓
     Renderer
        ↓
     Video
        ↓
      Judge
        ↓
      Repair
</code></pre>
<p>这其实和 Coding Agent 完全是同一种思想。</p>
<p>只是 Validator 从：</p>
<pre><code class="language-text">TypeScript Compiler
</code></pre>
<p>换成了：</p>
<pre><code class="language-text">Browser / Renderer / Vision Model / Video Evaluator
</code></pre>
<h2>十六、于是我们可以重新定义 Agent</h2>
<p>如果把这些案例全部放在一起：</p>
<table>
<thead>
<tr>
<th>技术</th>
<th>实际增加了什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prompt</td>
<td>行为规则</td>
</tr>
<tr>
<td>Chain of Thought</td>
<td>中间推理状态</td>
</tr>
<tr>
<td>Reasoning Model</td>
<td>更强的内部搜索/反思</td>
</tr>
<tr>
<td>Function Calling</td>
<td>外部行动能力</td>
</tr>
<tr>
<td>Tools</td>
<td>外部验证与信息获取</td>
</tr>
<tr>
<td>MCP</td>
<td>标准化工具/数据连接</td>
</tr>
<tr>
<td>Skills</td>
<td>程序化行为规范</td>
</tr>
<tr>
<td>Memory</td>
<td>跨任务状态</td>
</tr>
<tr>
<td>Code Execution</td>
<td>可执行反馈</td>
</tr>
<tr>
<td>Type Checker</td>
<td>形式约束</td>
</tr>
<tr>
<td>Unit Test</td>
<td>局部行为验证</td>
</tr>
<tr>
<td>E2E Test</td>
<td>系统行为验证</td>
</tr>
<tr>
<td>Browser</td>
<td>真实环境反馈</td>
</tr>
<tr>
<td>AGENTS.md</td>
<td>持久化行为规则</td>
</tr>
<tr>
<td>World Model</td>
<td>模拟环境反馈</td>
</tr>
<tr>
<td>Game Engine</td>
<td>可重复的环境与奖励</td>
</tr>
<tr>
<td>Remotion</td>
<td>将创意转换为可执行程序</td>
</tr>
<tr>
<td>Evaluator</td>
<td>外部质量判断</td>
</tr>
</tbody>
</table>
<p>你会发现：</p>
<p><strong>它们并不是互相独立的技术。</strong></p>
<p>它们全部在解决同一个问题：</p>
<blockquote>
<p><strong>如何限制一个概率模型的行为，使它最终产生我们真正想要的结果？</strong></p>
</blockquote>
<h2>十七、Harness 的真正定义</h2>
<p>所以，我更愿意把 Harness 定义为：</p>
<blockquote>
<p><strong>Harness 是包围在模型外部的一层控制系统，它负责提供上下文、约束模型行为、管理工具、执行动作、获取环境反馈、验证结果，并在失败时驱动模型重新尝试。</strong></p>
</blockquote>
<p>它至少可以拆成几个部分：</p>
<pre><code class="language-text">                 ┌──────────────┐
                 │     Goal     │
                 └──────┬───────┘
                        ↓
┌────────────────────────────────────────┐
│               HARNESS                  │
│                                        │
│  Context ───────→ LLM                  │
│                    │                   │
│  Rules ───────────→│                   │
│                    ↓                   │
│                 Decision                │
│                    │                   │
│                    ↓                   │
│                  Tools                 │
│                    │                   │
│                    ↓                   │
│                Environment             │
│                    │                   │
│                    ↓                   │
│                 Feedback               │
│                    │                   │
│                    ↓                   │
│                Evaluator               │
│                    │                   │
│               ┌────┴────┐              │
│               │         │              │
│             Pass       Fail            │
│               │         │              │
│               ↓         └────→ Retry   │
│             Done                       │
└────────────────────────────────────────┘
</code></pre>
<p>因此：</p>
<p><strong>Prompt 是 Harness 的最早形态。</strong></p>
<p><strong>Agent 是 Harness + Model 的系统化形态。</strong></p>
<p>而现代 Agent 的竞争，也正在逐渐从：</p>
<blockquote>
<p>谁的模型参数更多？</p>
</blockquote>
<p>转向：</p>
<blockquote>
<p><strong>谁能设计出更好的 Harness？</strong></p>
</blockquote>
<h2>十八、真正重要的不是“让 AI 更聪明”，而是“让错误变得昂贵”</h2>
<p>这是整个发展史中最值得注意的一点。</p>
<p>一个没有 Harness 的模型：</p>
<pre><code class="language-text">犯错
 ↓
输出
 ↓
结束
</code></pre>
<p>一个有 Harness 的 Agent：</p>
<pre><code class="language-text">犯错
 ↓
测试失败
 ↓
获得反馈
 ↓
重新思考
 ↓
修改
 ↓
再次测试
 ↓
……
</code></pre>
<p>Harness 并没有消灭模型犯错的能力。</p>
<p>它做的是另外一件事情：</p>
<blockquote>
<p><strong>让错误无法轻易通过系统。</strong></p>
</blockquote>
<p>这是一种非常工程化的思想。</p>
<p>软件工程从来没有假设程序员不会犯错。</p>
<p>所以我们建立：</p>
<pre><code class="language-text">Type System
Linter
Compiler
Unit Test
Integration Test
E2E Test
CI
Code Review
Production Monitoring
</code></pre>
<p>AI 现在正在逐渐获得完全类似的一套东西。</p>
<p>所以未来的 Agent，很可能不会是：</p>
<blockquote>
<p>一个越来越聪明、什么都知道的超级模型。</p>
</blockquote>
<p>而更可能是：</p>
<blockquote>
<p><strong>一个普通模型 + 极其优秀的 Harness。</strong></p>
</blockquote>
<h2>十九、从“模型中心”走向“系统中心”</h2>
<p>过去我们评价 AI：</p>
<pre><code class="language-text">GPT-4 比 GPT-3 强
R1 比某模型强
新模型 Benchmark 更高
</code></pre>
<p>但 Agent 时代，这种评价方式正在变得越来越不完整。</p>
<p>因为最终效果可能是：</p>
<pre><code class="language-text">Model A + Harness A = 80%
Model B + Harness B = 85%

Model A + Harness B = 95%
Model B + Harness A = 72%
</code></pre>
<p>这时候你很难再说：</p>
<blockquote>
<p>B 是一个更强的模型。</p>
</blockquote>
<p>因为真正决定任务成功率的，是整个系统。</p>
<p>2026 年已经出现了直接研究 Harness 本身的工作。例如 MemoHarness 将 Agent Harness 定义为管理上下文、工具、编排、记忆、解码和输出处理的外部控制层，并尝试让 Harness 从执行经验中持续学习。</p>
<p>甚至更进一步，Harness-R1 开始研究让一个专门的模型根据 Agent 的失败轨迹去修改其运行时 Harness，也就是：</p>
<pre><code class="language-text">Agent 失败
 ↓
分析失败轨迹
 ↓
修改 Harness
 ↓
重新运行
 ↓
观察成功率
 ↓
继续优化 Harness
</code></pre>
<p>这意味着一个非常有意思的递归：</p>
<blockquote>
<p><strong>我们不再只训练模型，也开始训练“如何控制模型”。</strong></p>
</blockquote>
<h2>二十、最终可能形成一个“验证递归”</h2>
<p>如果把整个 AI 发展史继续往前推，我们甚至可以得到这样一个循环：</p>
<pre><code class="language-text">模型生成答案
      ↓
Harness 验证答案
      ↓
发现错误
      ↓
Harness 修改上下文 / 工具 / 流程
      ↓
模型重新执行
      ↓
再次验证
      ↓
发现 Harness 本身存在问题
      ↓
优化 Harness
      ↓
……
</code></pre>
<p>于是：</p>
<pre><code class="language-text">Model
  ↓
Agent
  ↓
Harness
  ↓
Meta-Harness
  ↓
Self-Improving Harness
</code></pre>
<p>这可能是 Agent 下一阶段真正值得关注的方向。</p>
<h2>二十一、所以 Agent 到底是什么？</h2>
<p>如果一定要用一句话总结：</p>
<blockquote>
<p><strong>Agent 不是一个“会自己做事的 LLM”，而是一个被 Harness 包围的 LLM。</strong></p>
</blockquote>
<p>它通过：</p>
<pre><code class="language-text">Context
+
Rules
+
Reasoning
+
Tools
+
Memory
+
Environment
+
Verification
+
Feedback
</code></pre>
<p>形成一个闭环。</p>
<p>而这个闭环最重要的性质不是：</p>
<blockquote>
<p>AI 能不能一次性给出正确答案。</p>
</blockquote>
<p>而是：</p>
<blockquote>
<p><strong>AI 能不能发现自己的错误，并且在外部反馈的约束下不断逼近正确答案。</strong></p>
</blockquote>
<p>这也是为什么我们会看到一条非常清晰的发展路线：</p>
<pre><code class="language-text">Prompt
  ↓
Instruction
  ↓
Chain of Thought
  ↓
Reasoning
  ↓
Function Calling
  ↓
Tools
  ↓
MCP
  ↓
Skills
  ↓
Code Execution
  ↓
Tests
  ↓
E2E
  ↓
Environment
  ↓
World Model
  ↓
Self-improving Harness
</code></pre>
<p>表面上看，这是十几个完全不同的技术。</p>
<p>但从更高的抽象层来看，它们其实都在做同一件事情：</p>
<blockquote>
<p><strong>把“生成答案”变成“在约束环境中搜索正确答案”。</strong></p>
</blockquote>
<h2>结语：AI 的终点可能不是更大的模型，而是更好的“笼子”</h2>
<p>我们曾经认为：</p>
<blockquote>
<p>AI 不够聪明，所以要训练更大的模型。</p>
</blockquote>
<p>后来我们发现：</p>
<blockquote>
<p>即使模型很聪明，它仍然会犯错。</p>
</blockquote>
<p>于是我们开始给它思考。</p>
<p>然后给它工具。</p>
<p>给它搜索。</p>
<p>给它浏览器。</p>
<p>给它代码执行环境。</p>
<p>给它 MCP。</p>
<p>给它 Skills。</p>
<p>给它数据库。</p>
<p>给它编译器。</p>
<p>给它测试。</p>
<p>给它模拟器。</p>
<p>给它真实世界。</p>
<p>最后甚至开始研究：</p>
<blockquote>
<p><strong>如何让 Harness 自己学习。</strong></p>
</blockquote>
<p>这条路线的终点并不是把 AI 变成一个永远不会犯错的神。</p>
<p>恰恰相反。</p>
<p>我们逐渐接受了一个更加现实的工程事实：</p>
<blockquote>
<p><strong>模型一定会犯错。</strong></p>
</blockquote>
<p>真正重要的是：</p>
<blockquote>
<p><strong>不要让错误直接变成最终结果。</strong></p>
</blockquote>
<p>让模型思考。</p>
<p>让模型行动。</p>
<p>让环境反馈。</p>
<p>让程序验证。</p>
<p>让测试拒绝。</p>
<p>让 Agent 修复。</p>
<p>让它再次运行。</p>
<p>直到结果满足约束。</p>
<p>这或许就是 Agent Engineering 最核心的思想：</p>
<blockquote>
<p><strong>不是相信 AI，而是构建一个让 AI 必须不断证明自己的系统。</strong></p>
</blockquote>
<p>而所谓 <strong>Harness</strong>，本质上就是这样一个系统。</p>
<p>它不是限制 AI 的“笼子”。</p>
<p>它更像是 AI 能够可靠工作的<strong>轨道</strong>。</p>
<p>没有轨道，模型只能自由生成。</p>
<p>有了轨道，模型才真正开始执行。</p>
<p>而当轨道本身也能够从失败中学习时，我们才可能真正走向下一代 Agent。</p>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>agent</category>
            <category>人工智能</category>
            <category>harness</category>
            <enclosure url="https://blog.huanment.top/" length="0" type="image//"/>
        </item>
        <item>
            <title><![CDATA[探秘 Next.js PPR：让静态博客拥有动态评论]]></title>
            <link>https://blog.huanment.top/posts/nextjs-partial-prerendering</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/nextjs-partial-prerendering</guid>
            <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[深入理解部分预渲染 PPR 的工作原理，并以博客文章与评论系统为例，讲解如何在 Next.js 中组合静态、缓存与动态内容]]></description>
            <content:encoded><![CDATA[<h1>探秘 Next.js PPR：让静态博客拥有动态评论</h1>
<h2>前言</h2>
<p>在开发博客时，我们经常会遇到一个看似矛盾的问题。</p>
<p>博客文章通常不会频繁变化，因此非常适合静态生成。静态页面可以直接从 CDN 返回，拥有更快的响应速度、更低的服务器开销以及更好的搜索引擎优化效果。</p>
<p>但博客页面又不完全是静态的。</p>
<p>例如：</p>
<ul>
<li>评论需要显示最新数据</li>
<li>当前用户的登录状态因人而异</li>
<li>点赞状态需要根据当前用户计算</li>
<li>评论数量会不断发生变化</li>
<li>评论提交后应该立即出现在页面中</li>
</ul>
<p>如果使用纯静态生成，那么评论可能无法及时更新。</p>
<p>如果将整个文章页面改成动态渲染，那么每次访问都需要重新查询文章、解析 Markdown、生成目录并渲染页面。明明只有评论发生变化，却要让整个页面承担动态渲染的成本。</p>
<p>传统的渲染模式似乎要求我们二选一：</p>
<blockquote>
<p>要么选择快速但可能过期的静态页面，要么选择实时但成本更高的动态页面。</p>
</blockquote>
<p>而 PPR，也就是部分预渲染（Partial Prerendering），正是为了打破这种二选一而设计的。</p>
<p>它允许我们在同一个页面中同时使用：</p>
<ul>
<li>静态内容</li>
<li>缓存内容</li>
<li>动态内容</li>
</ul>
<p>对于博客而言，我们可以提前生成文章主体，在用户请求页面时立即返回；评论区域则在请求阶段查询数据库，并通过流式渲染填充到页面中。</p>
<p>最终效果是：</p>
<blockquote>
<p><strong>文章像静态网站一样快速，评论像动态应用一样实时。</strong></p>
</blockquote>
<h2>什么是 PPR</h2>
<p>PPR 的全称是 Partial Prerendering，中文通常翻译为<strong>部分预渲染</strong>。</p>
<p>它是一种将静态预渲染与动态服务端渲染组合在同一个路由中的渲染模式。</p>
<p>在传统的 Next.js 页面中，一个路由通常会被归类为：</p>
<ul>
<li>静态路由</li>
<li>动态路由</li>
</ul>
<p>只要页面中的某个组件读取了请求数据或者未缓存的数据，整个页面就可能需要在请求阶段动态渲染。</p>
<p>PPR 改变了这种以页面为单位的划分方式。</p>
<p>在 PPR 模式下，Next.js 会尝试在构建阶段预渲染组件树。能够提前确定的部分会被写入静态 HTML，而依赖请求或实时数据的部分会成为页面中的动态区域。</p>
<p>例如，一个博客详情页可以被拆分成下面几个部分：</p>
<pre><code class="language-mermaid">flowchart TD
    Page[博客详情页]

    Page --> Header[网站导航]
    Page --> Article[文章正文]
    Page --> Toc[文章目录]
    Page --> Comments[评论列表]
    Page --> User[当前用户状态]

    Header --> Static[静态 Shell]
    Article --> Cached[缓存内容]
    Toc --> Static
    Comments --> Dynamic[动态区域]
    User --> Dynamic

</code></pre>
<p>其中：</p>
<table>
<thead>
<tr>
<th>页面区域</th>
<th>渲染方式</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>网站导航</td>
<td>静态预渲染</td>
<td>所有用户看到的内容相同</td>
</tr>
<tr>
<td>文章标题</td>
<td>静态或缓存</td>
<td>文章不会频繁变化</td>
</tr>
<tr>
<td>Markdown 正文</td>
<td>静态或缓存</td>
<td>内容更新频率较低</td>
</tr>
<tr>
<td>文章目录</td>
<td>静态预渲染</td>
<td>可以根据文章提前计算</td>
</tr>
<tr>
<td>评论列表</td>
<td>动态渲染</td>
<td>评论可能随时增加</td>
</tr>
<tr>
<td>登录状态</td>
<td>动态渲染</td>
<td>依赖当前请求的 Cookie</td>
</tr>
<tr>
<td>评论输入框</td>
<td>客户端交互</td>
<td>需要状态和事件处理</td>
</tr>
</tbody>
</table>
<p>PPR 最重要的思想是：</p>
<blockquote>
<p><strong>静态和动态不再是页面级别的选择，而是组件级别的组合。</strong></p>
</blockquote>
<h2>PPR 是如何工作的</h2>
<p>开启 PPR 后，Next.js 会在构建阶段分析页面的组件树。</p>
<p>对于不依赖网络数据、请求信息或者其他运行时数据的组件，Next.js 可以直接完成预渲染，并将结果加入静态 Shell。</p>
<p>对于无法在构建阶段完成的组件，我们需要明确告诉 Next.js 如何处理：</p>
<ol>
<li>使用 <code>use cache</code> 缓存结果，使其能够加入静态 Shell</li>
<li>使用 <code>&#x3C;Suspense></code> 将其作为动态区域，推迟到请求阶段渲染</li>
</ol>
<p>假设博客页面包含文章和评论：</p>
<pre><code class="language-tsx">export default function PostPage() {
  return (
    &#x3C;main>
      &#x3C;Article />
      &#x3C;Comments />
    &#x3C;/main>
  )
}
</code></pre>
<p>如果 <code>Comments</code> 会实时查询数据库，那么它无法在构建阶段稳定地完成预渲染。</p>
<p>我们可以使用 <code>Suspense</code> 将评论区域隔离：</p>
<pre><code class="language-tsx">import { Suspense } from 'react'

export default function PostPage() {
  return (
    &#x3C;main>
      &#x3C;Article />

      &#x3C;Suspense fallback={&#x3C;CommentsSkeleton />}>
        &#x3C;Comments />
      &#x3C;/Suspense>
    &#x3C;/main>
  )
}
</code></pre>
<p>这样，Next.js 就可以先生成文章部分的静态 HTML。</p>
<p>用户访问页面时，服务器可以立即返回：</p>
<pre><code class="language-html">&#x3C;main>
  &#x3C;article>
    &#x3C;!-- 已经预渲染完成的文章 -->
  &#x3C;/article>

  &#x3C;section>
    &#x3C;!-- 评论骨架屏 -->
  &#x3C;/section>
&#x3C;/main>
</code></pre>
<p>与此同时，服务器开始查询评论数据。</p>
<p>查询完成后，评论区域通过流式响应继续发送到浏览器，并替换原来的骨架屏。</p>
<p>整个过程可以表示为：</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant User as 浏览器
    participant Next as Next.js
    participant Cache as 静态 Shell
    participant DB as 数据库

    User->>Next: 请求博客页面
    Next->>Cache: 读取预渲染结果
    Cache-->>User: 返回文章与评论骨架屏
    Next->>DB: 查询最新评论
    DB-->>Next: 返回评论数据
    Next-->>User: 流式发送评论区域
    User->>User: 替换评论骨架屏

</code></pre>
<p>用户不需要等待评论查询完成，就能先看到文章主体。</p>
<p>这就是 PPR 带来的核心体验：</p>
<blockquote>
<p><strong>让慢数据不再阻塞快内容。</strong></p>
</blockquote>
<h2>PPR、SSR 和 SSG 的区别</h2>
<p>理解 PPR 最简单的方法，是将它与传统的 SSG 和 SSR 进行比较。</p>
<h3>SSG：整个页面提前生成</h3>
<p>静态站点生成会在构建阶段完成整个页面的渲染。</p>
<pre><code class="language-mermaid">flowchart LR
    Build[构建阶段] --> HTML[生成完整 HTML]
    HTML --> CDN[部署到 CDN]
    User[用户请求] --> CDN
    CDN --> Response[立即返回页面]

</code></pre>
<p>它的优点是访问速度非常快。</p>
<p>但对于评论这样的实时数据来说，静态页面中的内容可能无法及时更新。</p>
<h3>SSR：整个页面请求时生成</h3>
<p>服务端渲染会在用户访问页面时查询数据并生成 HTML。</p>
<pre><code class="language-mermaid">flowchart LR
    User[用户请求] --> Server[服务器]
    Server --> DB[查询文章与评论]
    DB --> Server
    Server --> HTML[生成完整 HTML]
    HTML --> User

</code></pre>
<p>它能够保证数据新鲜，但用户必须等待整个页面完成渲染。</p>
<p>即使文章内容没有变化，也需要重新读取并渲染。</p>
<h3>PPR：静态 Shell 加动态区域</h3>
<p>PPR 将两种模式组合起来：</p>
<pre><code class="language-mermaid">flowchart LR
    User[用户请求] --> Shell[静态 Shell]
    Shell --> User

    User --> Dynamic[动态区域]
    Dynamic --> DB[查询实时数据]
    DB --> Dynamic
    Dynamic --> User

</code></pre>
<p>文章主体立即返回，评论稍后通过流式渲染补充。</p>
<p>三者可以总结为：</p>
<table>
<thead>
<tr>
<th>渲染模式</th>
<th>文章内容</th>
<th>评论内容</th>
<th>首屏速度</th>
<th>数据实时性</th>
</tr>
</thead>
<tbody>
<tr>
<td>SSG</td>
<td>构建时生成</td>
<td>构建时生成</td>
<td>快</td>
<td>较低</td>
</tr>
<tr>
<td>SSR</td>
<td>请求时生成</td>
<td>请求时生成</td>
<td>取决于最慢的数据</td>
<td>高</td>
</tr>
<tr>
<td>PPR</td>
<td>提前生成</td>
<td>请求时生成</td>
<td>快</td>
<td>高</td>
</tr>
</tbody>
</table>
<p>PPR 并不是一种完全独立的新渲染方式。</p>
<p>它更像是一种组合策略：</p>
<blockquote>
<p><strong>使用静态预渲染构建页面骨架，使用流式服务端渲染填充动态区域。</strong></p>
</blockquote>
<h2>在 Next.js 中开启 PPR</h2>
<p>在 Next.js 16 中，PPR 被整合进了 Cache Components。</p>
<p>我们需要在 <code>next.config.ts</code> 中开启 <code>cacheComponents</code>：</p>
<pre><code class="language-typescript">import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
</code></pre>
<p>开启之后，我们不再需要单独配置：</p>
<pre><code class="language-typescript">experimental: {
  ppr: true,
}

</code></pre>
<p><code>cacheComponents</code> 会统一启用与下面这些能力相关的渲染模型：</p>
<ul>
<li>Partial Prerendering</li>
<li><code>use cache</code></li>
<li><code>cacheLife</code></li>
<li><code>cacheTag</code></li>
<li>显式缓存控制</li>
<li>动态区域流式渲染</li>
</ul>
<p>需要注意的是，在 Cache Components 模式下，未缓存的数据访问默认发生在请求阶段。</p>
<p>如果某个组件读取了未缓存数据，我们应该：</p>
<ul>
<li>使用 <code>&#x3C;Suspense></code> 包裹它，将其声明为动态区域</li>
<li>或者使用 <code>use cache</code> 缓存它，让它参与预渲染</li>
</ul>
<p>否则，开发或构建时可能会遇到类似错误：</p>
<pre><code class="language-text">Uncached data was accessed outside of &#x3C;Suspense>

</code></pre>
<p>这个错误并不是在阻止我们使用动态数据。</p>
<p>它是在要求我们明确表达自己的渲染意图：</p>
<blockquote>
<p>这部分数据究竟应该被缓存，还是应该在请求阶段加载？</p>
</blockquote>
<h2>实际应用：博客文章与评论系统</h2>
<p>接下来，我们实现一个支持 PPR 的博客详情页。</p>
<p>目标是：</p>
<ul>
<li>文章正文提前生成并缓存</li>
<li>评论列表在请求阶段动态查询</li>
<li>评论加载时显示骨架屏</li>
<li>用户可以通过 Server Action 提交评论</li>
<li>提交成功后刷新评论区域</li>
</ul>
<p>项目结构如下：</p>
<pre><code class="language-text">app/
└── posts/
    └── [slug]/
        ├── actions.ts
        ├── page.tsx
        └── components/
            ├── article.tsx
            ├── comment-form.tsx
            ├── comment-list.tsx
            └── comment-skeleton.tsx

server/
├── queries/
│   ├── comment.query.ts
│   └── post.query.ts
└── db.ts

</code></pre>
<h2>第一步：缓存文章数据</h2>
<p>文章内容不会频繁变化，因此非常适合缓存。</p>
<p>我们可以为文章查询添加 <code>use cache</code>：</p>
<pre><code class="language-typescript">// server/queries/post.query.ts

import { cacheLife, cacheTag } from 'next/cache'

import { db } from '@/server/db'

export async function findPostBySlug(slug: string) {
  'use cache'

  cacheLife('weeks')
  cacheTag(`post:${slug}`)

  return db.query.posts.findFirst({
    where: (posts, { eq }) => eq(posts.slug, slug),
  })
}
</code></pre>
<p>这里做了三件事情。</p>
<h3><code>use cache</code></h3>
<p><code>use cache</code> 表示这个函数的返回结果可以被缓存。</p>
<p>函数参数会参与缓存键的生成，因此：</p>
<pre><code class="language-typescript">findPostBySlug('nextjs-ppr')
</code></pre>
<p>和：</p>
<pre><code class="language-typescript">findPostBySlug('react-server-components')
</code></pre>
<p>会生成不同的缓存条目。</p>
<h3><code>cacheLife('weeks')</code></h3>
<p><code>cacheLife</code> 用于声明缓存生命周期。</p>
<p>博客文章通常不会频繁修改，因此可以使用较长的缓存时间。</p>
<pre><code class="language-typescript">cacheLife('weeks')
</code></pre>
<p>这并不意味着文章修改后必须等待一周才能更新。</p>
<p>我们仍然可以通过缓存标签主动让指定文章失效。</p>
<h3><code>cacheTag</code></h3>
<p>下面这段代码为文章缓存添加了标签：</p>
<pre><code class="language-typescript">cacheTag(`post:${slug}`)
</code></pre>
<p>当 CMS、GitHub Webhook 或后台管理系统更新文章后，可以精准地重新验证这篇文章：</p>
<pre><code class="language-typescript">revalidateTag(`post:${slug}`, 'max')
</code></pre>
<p>这样不会误伤其他文章的缓存。</p>
<h2>第二步：创建文章组件</h2>
<p>文章组件调用已经缓存的数据查询：</p>
<pre><code class="language-tsx">// app/posts/[slug]/components/article.tsx

import { notFound } from 'next/navigation'

import { findPostBySlug } from '@/server/queries/post.query'

interface ArticleProps {
  slug: string
}

export async function Article({ slug }: ArticleProps) {
  const post = await findPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return (
    &#x3C;article>
      &#x3C;header>
        &#x3C;h1>{post.title}&#x3C;/h1>
        &#x3C;p>{post.description}&#x3C;/p>

        &#x3C;time dateTime={post.createdAt.toISOString()}>
          {post.createdAt.toLocaleDateString('zh-CN')}
        &#x3C;/time>
      &#x3C;/header>

      &#x3C;div>{post.content}&#x3C;/div>
    &#x3C;/article>
  )
}
</code></pre>
<p>因为 <code>findPostBySlug</code> 使用了 <code>use cache</code>，文章组件可以在预渲染阶段获得数据，并成为静态 Shell 的一部分。</p>
<p>真实项目中，<code>post.content</code> 可能是：</p>
<ul>
<li>Markdown</li>
<li>MDX</li>
<li>Tiptap JSON</li>
<li>已经生成的 HTML</li>
<li>来自 CMS 的富文本结构</li>
</ul>
<p>无论文章正文采用什么格式，只要文章查询处于缓存作用域中，它就可以参与预渲染。</p>
<h2>第三步：动态查询评论</h2>
<p>评论和文章不同。</p>
<p>评论随时可能发生变化，用户通常希望看到最新结果。</p>
<p>因此，在最强调实时性的方案中，我们不为评论查询添加 <code>use cache</code>：</p>
<pre><code class="language-typescript">// server/queries/comment.query.ts

import { db } from '@/server/db'

export async function findCommentsByPostId(postId: string) {
  return db.query.comments.findMany({
    where: (comments, { eq }) => eq(comments.postId, postId),
    orderBy: (comments, { desc }) => [desc(comments.createdAt)],
    with: {
      user: true,
    },
  })
}
</code></pre>
<p>这是一个未缓存的数据库查询。</p>
<p>在 Cache Components 模式下，它会被视为请求阶段的数据访问。</p>
<p>然后创建评论列表组件：</p>
<pre><code class="language-tsx">// app/posts/[slug]/components/comment-list.tsx

import { findCommentsByPostId } from '@/server/queries/comment.query'

interface CommentListProps {
  postId: string
}

export async function CommentList({ postId }: CommentListProps) {
  const comments = await findCommentsByPostId(postId)

  if (comments.length === 0) {
    return (
      &#x3C;div>
        &#x3C;h2>评论&#x3C;/h2>
        &#x3C;p>暂时还没有评论，来发表第一条评论吧。&#x3C;/p>
      &#x3C;/div>
    )
  }

  return (
    &#x3C;section>
      &#x3C;h2>评论（{comments.length}）&#x3C;/h2>

      &#x3C;ul>
        {comments.map(comment => (
          &#x3C;li key={comment.id}>
            &#x3C;header>
              &#x3C;strong>{comment.user.name}&#x3C;/strong>
              &#x3C;time dateTime={comment.createdAt.toISOString()}>
                {comment.createdAt.toLocaleString('zh-CN')}
              &#x3C;/time>
            &#x3C;/header>

            &#x3C;p>{comment.content}&#x3C;/p>
          &#x3C;/li>
        ))}
      &#x3C;/ul>
    &#x3C;/section>
  )
}
</code></pre>
<p>由于这个组件会访问未缓存的数据，所以它不能直接放在页面的预渲染区域中。</p>
<p>我们需要使用 <code>Suspense</code> 为它创建一个动态边界。</p>
<h2>第四步：创建评论骨架屏</h2>
<p>动态评论加载期间，页面需要显示一个有意义的占位内容：</p>
<pre><code class="language-tsx">// app/posts/[slug]/components/comment-skeleton.tsx

export function CommentSkeleton() {
  return (
    &#x3C;section aria-busy="true" aria-label="正在加载评论">
      &#x3C;h2>评论&#x3C;/h2>

      &#x3C;div className="space-y-4">
        {Array.from({ length: 3 }).map((_, index) => (
          &#x3C;div key={index} className="space-y-2">
            &#x3C;div className="h-4 w-32 animate-pulse rounded bg-muted" />
            &#x3C;div className="h-4 w-full animate-pulse rounded bg-muted" />
            &#x3C;div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
          &#x3C;/div>
        ))}
      &#x3C;/div>
    &#x3C;/section>
  )
}
</code></pre>
<p>骨架屏本身不依赖动态数据，因此可以直接加入静态 Shell。</p>
<p>这意味着用户收到的初始 HTML 中已经包含：</p>
<ul>
<li>网站导航</li>
<li>文章标题</li>
<li>文章正文</li>
<li>文章目录</li>
<li>评论区域的骨架屏</li>
</ul>
<p>只有真正的评论列表需要等待数据库查询。</p>
<h2>第五步：组合静态文章与动态评论</h2>
<p>现在可以在页面中组合这些组件：</p>
<pre><code class="language-tsx">// app/posts/[slug]/page.tsx

import { Suspense } from 'react'
import { notFound } from 'next/navigation'

import { findPostBySlug } from '@/server/queries/post.query'

import { Article } from './components/article'
import { CommentForm } from './components/comment-form'
import { CommentList } from './components/comment-list'
import { CommentSkeleton } from './components/comment-skeleton'

interface PostPageProps {
  params: Promise&#x3C;{
    slug: string
  }>
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params
  const post = await findPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return (
    &#x3C;main>
      &#x3C;Article slug={slug} />

      &#x3C;hr />

      &#x3C;CommentForm postId={post.id} />

      &#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
        &#x3C;CommentList postId={post.id} />
      &#x3C;/Suspense>
    &#x3C;/main>
  )
}
</code></pre>
<p>这里最关键的是：</p>
<pre><code class="language-tsx">&#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
  &#x3C;CommentList postId={post.id} />
&#x3C;/Suspense>
</code></pre>
<p>这段代码实际上是在告诉 Next.js：</p>
<blockquote>
<p>评论列表不需要阻塞整个页面。先生成页面的其他部分，在收到请求之后再查询评论，并将结果流式发送给浏览器。</p>
</blockquote>
<p>最终，这个页面形成了下面的渲染结构：</p>
<pre><code class="language-mermaid">flowchart TD
    Page[PostPage]

    Page --> Article[Article]
    Page --> Form[CommentForm]
    Page --> Suspense[Suspense Boundary]

    Article --> Cached[缓存并预渲染]
    Form --> Client[客户端交互组件]
    Suspense --> Fallback[静态骨架屏]
    Suspense --> Comments[请求阶段动态渲染]

    Comments --> Database[(数据库)]

</code></pre>
<h2>第六步：实现评论提交</h2>
<p>评论表单需要处理输入状态和用户交互，因此适合使用 Client Component。</p>
<p>首先创建 Server Action：</p>
<pre><code class="language-typescript">// app/posts/[slug]/actions.ts

'use server'

import { revalidatePath } from 'next/cache'

import { auth } from '@/lib/auth'
import { db } from '@/server/db'

export async function createComment(formData: FormData) {
  const postId = formData.get('postId')
  const content = formData.get('content')

  if (typeof postId !== 'string' || typeof content !== 'string') {
    throw new Error('评论参数不正确')
  }

  const normalizedContent = content.trim()

  if (!normalizedContent) {
    throw new Error('评论内容不能为空')
  }

  if (normalizedContent.length > 1000) {
    throw new Error('评论内容不能超过 1000 个字符')
  }

  const session = await auth.api.getSession({
    headers: await import('next/headers').then(({ headers }) => headers()),
  })

  if (!session) {
    throw new Error('请先登录后再发表评论')
  }

  await db.insert(comments).values({
    postId,
    userId: session.user.id,
    content: normalizedContent,
  })

  revalidatePath(`/posts/${postId}`)
}
</code></pre>
<p>然后创建评论表单：</p>
<pre><code class="language-tsx">// app/posts/[slug]/components/comment-form.tsx

'use client'

import { useActionState } from 'react'

import { createComment } from '../actions'

interface CommentFormProps {
  postId: string
}

export function CommentForm({ postId }: CommentFormProps) {
  const [state, action, pending] = useActionState(
    async (_state: { error?: string }, formData: FormData) => {
      try {
        await createComment(formData)
        return {}
      } catch (error) {
        return {
          error: error instanceof Error ? error.message : '评论提交失败',
        }
      }
    },
    {}
  )

  return (
    &#x3C;form action={action}>
      &#x3C;input type="hidden" name="postId" value={postId} />

      &#x3C;label htmlFor="comment-content">发表评论&#x3C;/label>

      &#x3C;textarea
        id="comment-content"
        name="content"
        maxLength={1000}
        placeholder="写下你的评论……"
        required
      />

      {state.error ? &#x3C;p role="alert">{state.error}&#x3C;/p> : null}

      &#x3C;button type="submit" disabled={pending}>
        {pending ? '正在提交……' : '发表评论'}
      &#x3C;/button>
    &#x3C;/form>
  )
}
</code></pre>
<p>评论写入数据库后，页面会重新获取动态区域的数据，从而显示最新评论。</p>
<p>不过，上面的示例使用 <code>postId</code> 生成页面路径并不一定符合真实项目的路由设计。如果你的页面路径使用 <code>slug</code>，更合理的方式是同时将 <code>slug</code> 传给 Server Action：</p>
<pre><code class="language-tsx">&#x3C;input type="hidden" name="slug" value={slug} />
</code></pre>
<p>然后执行：</p>
<pre><code class="language-typescript">revalidatePath(`/posts/${slug}`)
</code></pre>
<h2>评论一定不能缓存吗</h2>
<p>并不是。</p>
<p>评论是否缓存，需要根据产品需求决定。</p>
<h3>方案一：完全动态评论</h3>
<pre><code class="language-typescript">export async function findCommentsByPostId(postId: string) {
  return db.query.comments.findMany({
    where: (comments, { eq }) => eq(comments.postId, postId),
  })
}
</code></pre>
<p>优点：</p>
<ul>
<li>每次请求都能获得最新数据</li>
<li>实现方式直观</li>
<li>不需要处理评论缓存失效</li>
</ul>
<p>缺点：</p>
<ul>
<li>每次访问都要查询数据库</li>
<li>高流量文章可能给数据库带来压力</li>
<li>评论查询速度会影响动态区域出现的时间</li>
</ul>
<p>这种方案适合：</p>
<ul>
<li>评论量较少的个人博客</li>
<li>强调实时性的社区</li>
<li>访问量不高的项目</li>
<li>已经有数据库连接池或查询缓存的系统</li>
</ul>
<h3>方案二：短时间缓存评论</h3>
<p>如果博客流量较大，可以为评论添加短期缓存：</p>
<pre><code class="language-typescript">// server/queries/comment.query.ts

import { cacheLife, cacheTag } from 'next/cache'

import { db } from '@/server/db'

export async function findCommentsByPostId(postId: string) {
  'use cache'

  cacheLife('minutes')
  cacheTag(`comments:${postId}`)

  return db.query.comments.findMany({
    where: (comments, { eq }) => eq(comments.postId, postId),
    orderBy: (comments, { desc }) => [desc(comments.createdAt)],
    with: {
      user: true,
    },
  })
}
</code></pre>
<p>评论提交后，通过 <code>updateTag</code> 让缓存立即失效：</p>
<pre><code class="language-typescript">'use server'

import { updateTag } from 'next/cache'

export async function createComment(formData: FormData) {
  const postId = String(formData.get('postId'))
  const content = String(formData.get('content')).trim()

  await db.insert(comments).values({
    postId,
    content,
    userId: 'current-user-id',
  })

  updateTag(`comments:${postId}`)
}
</code></pre>
<p><code>updateTag</code> 适合评论提交这样的场景，因为用户完成写操作后，通常希望立即看到自己刚刚发布的内容。</p>
<p>它提供的是“读取自己的写入”语义：</p>
<blockquote>
<p>用户提交评论后，下一次读取会等待新数据，而不是继续返回旧缓存。</p>
</blockquote>
<h3>方案三：后台重新验证评论</h3>
<p>如果评论是通过 Route Handler、Webhook 或后台审核系统更新的，可以使用：</p>
<pre><code class="language-typescript">revalidateTag(`comments:${postId}`, 'max')
</code></pre>
<p>它会将缓存标记为过期，并采用 stale-while-revalidate 行为。</p>
<p>也就是说：</p>
<ol>
<li>用户可以先获得旧评论</li>
<li>Next.js 在后台重新查询评论</li>
<li>后续请求获得新的评论数据</li>
</ol>
<p>这种方式响应速度更稳定，但刚刚提交的评论可能不会立即出现。</p>
<p>因此可以这样选择：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>推荐方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>评论必须绝对实时</td>
<td>不缓存评论</td>
</tr>
<tr>
<td>用户提交后必须立即看到</td>
<td><code>updateTag</code></td>
</tr>
<tr>
<td>可以短暂显示旧数据</td>
<td><code>revalidateTag(tag, 'max')</code></td>
</tr>
<tr>
<td>评论更新频率较低</td>
<td><code>use cache</code> + <code>cacheLife</code></td>
</tr>
<tr>
<td>后台审核通过后刷新</td>
<td>Route Handler + <code>revalidateTag</code></td>
</tr>
</tbody>
</table>
<h2>PPR 不等于客户端请求</h2>
<p>看到动态区域之后，有些人可能会认为：</p>
<blockquote>
<p>这不就是先生成文章，然后在浏览器里通过 <code>useEffect</code> 请求评论吗？</p>
</blockquote>
<p>两者看起来相似，但实现方式完全不同。</p>
<p>客户端请求通常是：</p>
<pre><code class="language-tsx">'use client'

export function Comments() {
  const [comments, setComments] = useState([])

  useEffect(() => {
    fetch('/api/comments')
      .then(response => response.json())
      .then(setComments)
  }, [])

  return &#x3C;CommentList comments={comments} />
}
</code></pre>
<p>这种方式需要经历：</p>
<ol>
<li>下载页面 HTML</li>
<li>下载 JavaScript</li>
<li>执行 React 水合</li>
<li>执行 <code>useEffect</code></li>
<li>请求评论接口</li>
<li>客户端渲染评论</li>
</ol>
<p>而 PPR 中的动态评论仍然可以是 Server Component：</p>
<pre><code class="language-tsx">export async function CommentList({ postId }: CommentListProps) {
  const comments = await findCommentsByPostId(postId)

  return &#x3C;CommentItems comments={comments} />
}
</code></pre>
<p>它不需要为了查询评论而向浏览器发送额外的业务 JavaScript。</p>
<p>评论 HTML 由服务器生成，并通过 React Server Components 的流式响应发送到浏览器。</p>
<p>两种方式的区别可以总结为：</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>客户端请求</th>
<th>PPR 动态区域</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据查询位置</td>
<td>浏览器</td>
<td>服务器</td>
</tr>
<tr>
<td>是否需要额外 API</td>
<td>通常需要</td>
<td>不一定需要</td>
</tr>
<tr>
<td>是否增加客户端 JavaScript</td>
<td>是</td>
<td>查询逻辑不会</td>
</tr>
<tr>
<td>是否能直接访问数据库</td>
<td>否</td>
<td>可以</td>
</tr>
<tr>
<td>是否支持流式 HTML</td>
<td>通常不支持</td>
<td>支持</td>
</tr>
<tr>
<td>是否需要等待水合后请求</td>
<td>是</td>
<td>否</td>
</tr>
<tr>
<td>敏感数据是否暴露给客户端</td>
<td>需要谨慎处理</td>
<td>查询代码只在服务端</td>
</tr>
</tbody>
</table>
<p>PPR 并不是把动态内容推给客户端处理。</p>
<p>它做的是：</p>
<blockquote>
<p><strong>让服务器可以分阶段返回页面。</strong></p>
</blockquote>
<h2>PPR 和 Suspense 的关系</h2>
<p>PPR 并不是单独完成动态渲染的。</p>
<p>它依赖 React Suspense 来划分静态区域和动态区域。</p>
<pre><code class="language-tsx">&#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
  &#x3C;CommentList postId={postId} />
&#x3C;/Suspense>
</code></pre>
<p>这里的 <code>Suspense</code> 同时承担了两个职责。</p>
<h3>第一层职责：定义加载状态</h3>
<p>当评论还没有准备好时，展示：</p>
<pre><code class="language-tsx">&#x3C;CommentSkeleton />
</code></pre>
<h3>第二层职责：定义渲染边界</h3>
<p>它告诉 Next.js：</p>
<pre><code class="language-text">这个子树可以晚一点完成，不需要阻塞页面的其他部分。

</code></pre>
<p>因此，设计 PPR 页面时，Suspense 边界的位置非常重要。</p>
<p>如果边界过大：</p>
<pre><code class="language-tsx">&#x3C;Suspense fallback={&#x3C;PageSkeleton />}>
  &#x3C;Article />
  &#x3C;Comments />
&#x3C;/Suspense>
</code></pre>
<p>评论查询可能会导致文章也只能显示骨架屏。</p>
<p>如果边界足够精细：</p>
<pre><code class="language-tsx">&#x3C;Article />

&#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
  &#x3C;Comments />
&#x3C;/Suspense>

</code></pre>
<p>文章可以立即出现，只有评论区域需要等待。</p>
<p>这体现了一个重要原则：</p>
<blockquote>
<p><strong>Suspense 应该尽量靠近真正的动态数据。</strong></p>
</blockquote>
<h2>一个页面可以有多个动态区域</h2>
<p>PPR 页面不只能有一个动态区域。</p>
<p>博客详情页可能同时包含：</p>
<ul>
<li>评论列表</li>
<li>用户登录状态</li>
<li>当前用户是否点赞</li>
<li>阅读量</li>
<li>推荐文章</li>
<li>个性化广告</li>
</ul>
<p>我们可以分别为它们创建 Suspense 边界：</p>
<pre><code class="language-tsx">export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params

  return (
    &#x3C;main>
      &#x3C;Article slug={slug} />

      &#x3C;Suspense fallback={&#x3C;ViewCountSkeleton />}>
        &#x3C;ViewCount slug={slug} />
      &#x3C;/Suspense>

      &#x3C;Suspense fallback={&#x3C;ReactionSkeleton />}>
        &#x3C;ReactionPanel slug={slug} />
      &#x3C;/Suspense>

      &#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
        &#x3C;CommentList slug={slug} />
      &#x3C;/Suspense>
    &#x3C;/main>
  )
}
</code></pre>
<p>这些动态区域可以独立加载。</p>
<pre><code class="language-mermaid">flowchart TD
    Shell[静态文章 Shell]

    Shell --> Views[阅读量]
    Shell --> Reactions[表情回应]
    Shell --> Comments[评论列表]

    Views --> Redis[(Redis)]
    Reactions --> Database[(数据库)]
    Comments --> Database

</code></pre>
<p>如果阅读量查询很快，它可以先显示。</p>
<p>即使评论查询较慢，也不会阻塞阅读量和表情回应。</p>
<p>这不仅提升了首屏速度，也避免了不同数据源之间相互拖累。</p>
<h2>PPR 会自动提升性能吗</h2>
<p>PPR 提供了一套更灵活的渲染机制，但并不意味着开启之后页面就一定更快。</p>
<p>性能仍然取决于我们如何划分组件。</p>
<h3>不要在页面顶部读取动态数据</h3>
<p>下面的代码会让整个页面等待评论查询：</p>
<pre><code class="language-tsx">export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params
  const comments = await findCommentsBySlug(slug)

  return (
    &#x3C;main>
      &#x3C;Article slug={slug} />
      &#x3C;CommentList comments={comments} />
    &#x3C;/main>
  )
}
</code></pre>
<p>虽然 <code>CommentList</code> 看起来是独立组件，但数据已经在页面顶部被 <code>await</code>。</p>
<p>更好的方式是让动态组件自己读取数据：</p>
<pre><code class="language-tsx">export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params

  return (
    &#x3C;main>
      &#x3C;Article slug={slug} />

      &#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
        &#x3C;CommentList slug={slug} />
      &#x3C;/Suspense>
    &#x3C;/main>
  )
}
</code></pre>
<p>然后在 <code>CommentList</code> 内部查询：</p>
<pre><code class="language-tsx">export async function CommentList({ slug }: { slug: string }) {
  const comments = await findCommentsBySlug(slug)

  return &#x3C;CommentItems comments={comments} />
}
</code></pre>
<h3>不要创建一个巨大的 Suspense 边界</h3>
<p>下面的写法会让所有动态区域共用一个加载状态：</p>
<pre><code class="language-tsx">&#x3C;Suspense fallback={&#x3C;PageSkeleton />}>
  &#x3C;ViewCount />
  &#x3C;ReactionPanel />
  &#x3C;CommentList />
&#x3C;/Suspense>
</code></pre>
<p>只要其中一个组件较慢，整个区域都无法显示。</p>
<p>更合理的方式是分别创建边界：</p>
<pre><code class="language-tsx">&#x3C;Suspense fallback={&#x3C;ViewCountSkeleton />}>
  &#x3C;ViewCount />
&#x3C;/Suspense>

&#x3C;Suspense fallback={&#x3C;ReactionSkeleton />}>
  &#x3C;ReactionPanel />
&#x3C;/Suspense>

&#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
  &#x3C;CommentList />
&#x3C;/Suspense>

</code></pre>
<h3>不要缓存所有东西</h3>
<p><code>use cache</code> 并不是越多越好。</p>
<p>下面这些内容通常不应该放入公共缓存：</p>
<ul>
<li>当前用户的登录状态</li>
<li>当前用户是否点赞</li>
<li>当前用户的通知数量</li>
<li>根据 Cookie 生成的个性化内容</li>
<li>权限相关数据</li>
<li>必须绝对实时的数据</li>
</ul>
<p>缓存之前应该先问自己：</p>
<blockquote>
<p>不同用户是否可以安全地获得同一份结果？</p>
</blockquote>
<p>如果答案是否定的，就不应该直接使用公共 <code>use cache</code>。</p>
<h2>PPR 与 ISR 有什么区别</h2>
<p>PPR 和 ISR 经常同时出现，但它们解决的不是同一个问题。</p>
<p>ISR 的核心问题是：</p>
<blockquote>
<p>已经生成的缓存内容应该在什么时候更新？</p>
</blockquote>
<p>PPR 的核心问题是：</p>
<blockquote>
<p>一个页面中的哪些部分应该提前生成，哪些部分应该在请求时生成？</p>
</blockquote>
<p>例如，在博客页面中：</p>
<pre><code class="language-mermaid">flowchart TD
    Page[博客详情页]

    Page --> Article[文章正文]
    Page --> Comments[评论列表]

    Article --> ISR[缓存与按需重新验证]
    Comments --> PPRDynamic[请求阶段动态渲染]

</code></pre>
<p>文章正文可以使用：</p>
<pre><code class="language-typescript">'use cache'
cacheLife('weeks')
cacheTag(`post:${slug}`)
</code></pre>
<p>这部分主要体现缓存和重新验证机制。</p>
<p>评论组件使用：</p>
<pre><code class="language-tsx">&#x3C;Suspense fallback={&#x3C;CommentSkeleton />}>
  &#x3C;CommentList postId={postId} />
&#x3C;/Suspense>
</code></pre>
<p>这部分主要体现 PPR 的动态区域。</p>
<p>因此，一个页面完全可以同时使用 PPR 和类似 ISR 的缓存更新策略。</p>
<p>两者的关系可以总结为：</p>
<table>
<thead>
<tr>
<th>技术</th>
<th>解决的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>PPR</td>
<td>一个页面如何组合静态与动态区域</td>
</tr>
<tr>
<td><code>use cache</code></td>
<td>哪些函数或组件结果应该缓存</td>
</tr>
<tr>
<td><code>cacheLife</code></td>
<td>缓存可以使用多久</td>
</tr>
<tr>
<td><code>cacheTag</code></td>
<td>如何标记缓存</td>
</tr>
<tr>
<td><code>revalidateTag</code></td>
<td>如何让缓存进入后台重新验证</td>
</tr>
<tr>
<td><code>updateTag</code></td>
<td>如何让用户立即看到自己的写入</td>
</tr>
<tr>
<td>Suspense</td>
<td>哪些组件可以延迟并流式渲染</td>
</tr>
</tbody>
</table>
<p>PPR 关注的是页面结构。</p>
<p>ISR 和 Cache Components 的重新验证机制关注的是数据生命周期。</p>
<h2>PPR 最适合哪些场景</h2>
<p>PPR 最适合页面主体相对稳定，但局部区域需要动态数据的场景。</p>
<h3>博客文章</h3>
<p>静态部分：</p>
<ul>
<li>标题</li>
<li>正文</li>
<li>封面</li>
<li>标签</li>
<li>文章目录</li>
</ul>
<p>动态部分：</p>
<ul>
<li>评论</li>
<li>阅读量</li>
<li>点赞状态</li>
<li>当前用户信息</li>
</ul>
<h3>电商商品页</h3>
<p>静态或缓存部分：</p>
<ul>
<li>商品名称</li>
<li>商品描述</li>
<li>商品图片</li>
</ul>
<p>动态部分：</p>
<ul>
<li>实时库存</li>
<li>用户购物车</li>
<li>个性化推荐</li>
<li>当前价格</li>
</ul>
<h3>文档网站</h3>
<p>静态部分：</p>
<ul>
<li>文档正文</li>
<li>代码示例</li>
<li>页面导航</li>
</ul>
<p>动态部分：</p>
<ul>
<li>用户登录状态</li>
<li>搜索建议</li>
<li>反馈组件</li>
<li>个性化历史记录</li>
</ul>
<h3>社区帖子</h3>
<p>静态或缓存部分：</p>
<ul>
<li>帖子正文</li>
<li>作者公开信息</li>
</ul>
<p>动态部分：</p>
<ul>
<li>回复列表</li>
<li>点赞状态</li>
<li>在线状态</li>
<li>审核结果</li>
</ul>
<p>它们都有同一个特点：</p>
<blockquote>
<p>页面的大部分内容可以提前生成，只有少部分内容必须在请求阶段确定。</p>
</blockquote>
<h2>PPR 不适合哪些场景</h2>
<p>如果页面几乎所有内容都依赖当前用户，那么 PPR 带来的收益可能有限。</p>
<p>例如：</p>
<ul>
<li>私人管理后台</li>
<li>在线聊天页面</li>
<li>实时监控面板</li>
<li>高度个性化的信息流</li>
<li>每个用户内容都完全不同的工作台</li>
</ul>
<p>这些页面几乎不存在可复用的静态 Shell。</p>
<p>此时，与其强行拆分静态区域，不如直接使用动态 Server Components、客户端数据请求或者专门的实时通信方案。</p>
<p>PPR 不是为了消灭动态渲染。</p>
<p>它解决的是：</p>
<blockquote>
<p>不要因为页面中存在一小块动态内容，就放弃其余内容的预渲染能力。</p>
</blockquote>
<h2>总结</h2>
<p>传统的渲染模式习惯以页面为单位思考：</p>
<ul>
<li>这个页面是静态页面</li>
<li>这个页面是动态页面</li>
</ul>
<p>PPR 则要求我们以组件树为单位思考：</p>
<ul>
<li>哪些组件可以提前渲染</li>
<li>哪些数据适合缓存</li>
<li>哪些组件依赖请求信息</li>
<li>哪些区域必须读取实时数据</li>
<li>哪些动态区域可以独立加载</li>
</ul>
<p>以博客评论系统为例，我们可以将页面拆分为：</p>
<pre><code class="language-mermaid">flowchart LR
    Blog[博客页面]

    Blog --> Static[静态 Shell]
    Blog --> Cached[缓存内容]
    Blog --> Dynamic[动态区域]
    Blog --> Client[客户端交互]

    Static --> Header[导航与布局]
    Cached --> Article[文章正文]
    Dynamic --> Comments[评论列表]
    Dynamic --> Session[用户状态]
    Client --> Form[评论输入框]

</code></pre>
<p>文章正文使用 <code>use cache</code>、<code>cacheLife</code> 和 <code>cacheTag</code>，获得接近静态页面的访问性能。</p>
<p>评论列表放入 Suspense 边界，在请求阶段查询数据库，并通过流式渲染发送给浏览器。</p>
<p>评论表单使用 Client Component 提供交互，通过 Server Action 写入数据库。</p>
<p>评论提交后，再根据一致性要求选择：</p>
<ul>
<li>完全不缓存</li>
<li><code>updateTag</code></li>
<li><code>revalidateTag(tag, 'max')</code></li>
</ul>
<p>最终，我们不再需要为了几条实时评论，将整篇文章变成动态页面。</p>
<p>这正是 PPR 最有价值的地方：</p>
<blockquote>
<p><strong>它保留了静态页面的速度，同时赋予局部区域动态应用的能力。</strong></p>
</blockquote>
<p>PPR 并不是简单地把一个页面切成“静态一半”和“动态一半”。</p>
<p>它真正带来的是一种新的页面设计思维：</p>
<blockquote>
<p><strong>缓存应该是局部的，动态也应该是局部的。</strong></p>
</blockquote>
<p>当静态、缓存、动态和客户端交互可以在同一个组件树中自由组合时，Next.js 页面不再需要在 SSG 和 SSR 之间做出非黑即白的选择。</p>
<p>文章可以保持稳定与快速，评论可以保持实时与鲜活。</p>
<p>这或许就是部分预渲染最准确的定义：</p>
<blockquote>
<p><strong>只在真正需要动态的地方动态渲染。</strong></p>
</blockquote>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>Next.js</category>
            <category>PPR</category>
            <category>部分预渲染</category>
            <category>Cache Components</category>
            <category>React</category>
            <enclosure url="https://blog.huanment.top/images/posts/nextjs-partial-prerendering.avif" length="0" type="image/avif"/>
        </item>
        <item>
            <title><![CDATA[探秘博客构建的秘诀：ISR 增量静态再生与自动化内容更新]]></title>
            <link>https://blog.huanment.top/posts/blog-isr</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/blog-isr</guid>
            <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[你也不想你的博客在更新之后需要重新构建整个网站吧，那也太费时费力了。不妨尝试一下 ISR 增量静态再生，让你的博客获得闪电般的更新体验。记录我在 Next.js 博客中使用 ISR、Webhook 与 GitHub Actions 实现自动化内容更新的实践，以及一次关于 Vercel 架构的踩坑经历]]></description>
            <content:encoded><![CDATA[<h1>探秘博客构建的秘诀：ISR 增量静态再生与自动化内容更新</h1>
<p>在构建一个现代博客系统时，性能与开发体验往往是一对矛盾。</p>
<p>传统动态网站：</p>
<pre><code>用户访问
 ↓
服务器查询数据库
 ↓
生成页面
 ↓
返回 HTML
</code></pre>
<p>虽然数据实时，但是每一次访问都需要服务器计算。</p>
<p>而纯静态网站：</p>
<pre><code>next build
 ↓
生成 HTML
 ↓
部署 CDN
</code></pre>
<p>拥有极快的访问速度，但是每次内容更新都需要重新构建整个项目。</p>
<p>对于博客、文档站这类内容驱动型网站来说，我们希望同时拥有：</p>
<ul>
<li>静态页面的极致性能；</li>
<li>动态内容的快速更新；</li>
<li>无需手动重新部署。</li>
</ul>
<p>这也是我最终选择：</p>
<p><strong>Next.js ISR + Webhook + GitHub Actions</strong></p>
<p>的原因。</p>
<h2>一次失败的 Webhook 架构</h2>
<p>最开始，我设计了一套看起来很合理的流程：</p>
<pre><code>Pages CMS

    ↓

GitHub Commit

    ↓

GitHub Webhook

    ↓

Vercel API Route

    ↓

syncPosts()

    ↓

读取 content/posts

    ↓

更新数据库

    ↓

revalidateTag()
</code></pre>
<p>理论上：</p>
<p>CMS 修改文章后：</p>
<ol>
<li>提交 Markdown 文件；</li>
<li>GitHub 通知 Vercel；</li>
<li>Vercel 同步最新文章；</li>
<li>刷新 ISR 缓存。</li>
</ol>
<p>但是实际运行后发现：</p>
<p>无论怎么修改文章，Webhook 触发之后始终返回：</p>
<pre><code>no content changed
</code></pre>
<p>刚开始我以为是同步逻辑的问题。</p>
<p>但是进一步排查后发现：</p>
<p>真正的问题在于：</p>
<p><strong>Vercel Runtime 并不是 Git 仓库。</strong></p>
<h2>Vercel Deployment 的核心机制</h2>
<p>很多人第一次使用 Serverless 平台时容易产生一个误区：</p>
<blockquote>
<p>GitHub 更新了代码，Vercel Function 里面的文件也会自动更新。</p>
</blockquote>
<p>实际上并不是这样。</p>
<p>Vercel 的部署模型：</p>
<pre><code>GitHub Repository

        ↓

      Build

        ↓

 Immutable Deployment

        ↓

 Serverless Function
</code></pre>
<p>一次部署生成的是一个不可变快照。</p>
<p>例如：</p>
<p>第一次部署：</p>
<pre><code>GitHub main

content/posts

├── hello-world.mdx
└── nextjs.mdx


        ↓


Vercel Build


        ↓


Deployment A


        ↓


Function Runtime
</code></pre>
<p>此时：</p>
<p>Function 中携带的是：</p>
<pre><code>hello-world.mdx
nextjs.mdx
</code></pre>
<hr>
<p>之后：</p>
<p>GitHub：</p>
<pre><code>content/posts

├── hello-world.mdx
├── nextjs.mdx
└── new-post.mdx
</code></pre>
<p>发生变化。</p>
<p>但是：</p>
<pre><code>GitHub Repository

        ↓

新的 commit
</code></pre>
<p>不会自动修改：</p>
<pre><code>Deployment A

        ↓

Function Runtime
</code></pre>
<p>所以：</p>
<pre><code>syncPosts()
</code></pre>
<p>读取到的依然是：</p>
<pre><code>旧文章列表
</code></pre>
<p>最终自然得到：</p>
<pre><code>no content changed
</code></pre>
<h2>正确的架构应该是什么？</h2>
<p>发现问题之后，我重新调整了架构。</p>
<p>新的流程：</p>
<pre><code>Pages CMS

    ↓

GitHub Commit

    ↓

GitHub Actions

    ↓

checkout 最新代码

    ↓

执行同步脚本

    ↓

更新数据库

    ↓

调用 Next.js API

    ↓

revalidateTag()

    ↓

ISR 更新缓存
</code></pre>
<p>核心变化：</p>
<p><strong>GitHub Actions 负责获取最新代码。</strong></p>
<p>因为 Actions 才是真正拥有 Git 仓库工作区的环境。</p>
<h2>GitHub Actions 为什么适合这个场景？</h2>
<p>很多人会认为：</p>
<blockquote>
<p>GitHub Actions 只适合重新构建项目。</p>
</blockquote>
<p>其实并不是。</p>
<p>Actions 本质上就是一个 CI Runner。</p>
<p>它可以：</p>
<ul>
<li>拉取代码；</li>
<li>执行脚本；</li>
<li>调用 API；</li>
<li>操作数据库。</li>
</ul>
<p>因此：</p>
<p>这里不需要：</p>
<pre><code>GitHub Push

↓

Vercel Build
</code></pre>
<p>而是：</p>
<pre><code>GitHub Push

↓

Actions

↓

同步数据

↓

触发 ISR
</code></pre>
<p>避免了：</p>
<ul>
<li>不必要的重新部署；</li>
<li>浪费构建时间；</li>
<li>增加部署成本。</li>
</ul>
<h2>什么是 ISR？</h2>
<p>ISR：</p>
<p>Incremental Static Regeneration</p>
<p>中文：</p>
<p>增量静态再生。</p>
<p>它的目标：</p>
<blockquote>
<p>在保持静态页面性能的同时，让页面可以动态更新。</p>
</blockquote>
<h2>SSR：每次请求生成页面</h2>
<p>服务端渲染：</p>
<pre><code>用户请求

 ↓

服务器查询数据

 ↓

生成 HTML

 ↓

返回浏览器
</code></pre>
<p>优点：</p>
<ul>
<li>数据实时。</li>
</ul>
<p>缺点：</p>
<ul>
<li>每次访问都有计算成本。</li>
</ul>
<h2>SSG：构建时生成页面</h2>
<p>静态生成：</p>
<pre><code>next build

 ↓

读取数据

 ↓

生成 HTML
</code></pre>
<p>访问：</p>
<pre><code>用户

 ↓

CDN

 ↓

HTML
</code></pre>
<p>优点：</p>
<ul>
<li>极快；</li>
<li>几乎没有服务器压力。</li>
</ul>
<p>但是：</p>
<p>内容更新：</p>
<pre><code>修改文章

 ↓

重新 build

 ↓

重新部署
</code></pre>
<p>对于博客并不方便。</p>
<h2>ISR：静态页面的局部更新</h2>
<p>ISR 可以理解为：</p>
<blockquote>
<p>让静态页面拥有重新生成能力。</p>
</blockquote>
<p>流程：</p>
<p>第一次访问：</p>
<pre><code>用户请求

 ↓

生成页面

 ↓

缓存
</code></pre>
<p>之后：</p>
<pre><code>用户请求

 ↓

读取缓存
</code></pre>
<p>当内容发生变化：</p>
<pre><code>触发重新验证

 ↓

旧缓存失效

 ↓

下一次请求重新生成
</code></pre>
<p>整个过程：</p>
<p>不需要重新部署整个网站。</p>
<h2>Next.js 中使用 ISR</h2>
<p>在新版 Next.js 中，我使用：</p>
<p><strong>Cache Components 模式</strong></p>
<p>代替传统：</p>
<pre><code class="language-ts">export const revalidate = 3600
</code></pre>
<p>方式。</p>
<p>例如：</p>
<pre><code class="language-ts">import { cacheLife, cacheTag } from 'next/cache'

export async function getPosts() {
  'use cache'

  cacheTag('posts')

  cacheLife('weeks')

  return await findPosts()
}
</code></pre>
<h3>use cache</h3>
<pre><code class="language-ts">'use cache'
</code></pre>
<p>表示：</p>
<p>这个函数返回值应该被缓存。</p>
<h3>cacheTag</h3>
<pre><code class="language-ts">cacheTag('posts')
</code></pre>
<p>为缓存添加标签。</p>
<p>例如：</p>
<pre><code>posts

 ├── 首页文章列表

 ├── RSS

 └── 文章归档
</code></pre>
<p>之后：</p>
<pre><code class="language-ts">revalidateTag('posts')
</code></pre>
<p>即可刷新所有相关缓存。</p>
<h3>cacheLife</h3>
<pre><code class="language-ts">cacheLife('weeks')
</code></pre>
<p>表示：</p>
<p>缓存生命周期。</p>
<p>博客内容通常不会每分钟变化，所以可以设置较长缓存。</p>
<p>但是：</p>
<p>我们仍然需要一种主动刷新机制。</p>
<p>这就是 Webhook。</p>
<h2>什么是 Webhook？</h2>
<p>Webhook 是一种：</p>
<p>事件驱动通知机制。</p>
<p>传统 API：</p>
<pre><code>你的程序

 ↓

主动请求

 ↓

第三方服务
</code></pre>
<p>例如：</p>
<p>每分钟询问：</p>
<pre><code>有没有新文章？
</code></pre>
<p>这种叫：</p>
<p>Polling（轮询）</p>
<p>缺点：</p>
<ul>
<li>浪费请求；</li>
<li>有延迟。</li>
</ul>
<hr>
<p>Webhook：</p>
<pre><code>内容变化

 ↓

主动发送 HTTP 请求

 ↓

通知目标系统
</code></pre>
<p>例如：</p>
<pre><code>CMS

文章更新

 ↓

Webhook

 ↓

你的服务
</code></pre>
<h2>我的博客如何使用 Webhook？</h2>
<p>最终架构：</p>
<pre><code>Pages CMS

    ↓

GitHub Commit

    ↓

GitHub Actions

    ↓

syncPosts()

    ↓

数据库更新

    ↓

POST /api/revalidate

    ↓

revalidateTag()

    ↓

ISR Cache 更新
</code></pre>
<h2>Next.js 接收刷新请求</h2>
<p>Route Handler：</p>
<p><code>app/api/revalidate/posts/route.ts</code></p>
<p>示例：</p>
<pre><code class="language-ts">import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const auth = req.headers.get('authorization')

  if (auth !== `Bearer ${process.env.WEBHOOK_SECRET}`) {
    return new Response('Unauthorized', {
      status: 401,
    })
  }

  revalidateTag('posts', 'max')

  return Response.json({
    success: true,
  })
}
</code></pre>
<h2>为什么需要鉴权？</h2>
<p>Webhook 本质上是公开接口。</p>
<p>如果没有保护：</p>
<p>任何人都可以：</p>
<pre><code class="language-bash">POST /api/revalidate/posts
</code></pre>
<p>导致：</p>
<ul>
<li>无限刷新缓存；</li>
<li>消耗服务器资源；</li>
<li>影响网站稳定性。</li>
</ul>
<p>因此需要：</p>
<pre><code>Authorization:

Bearer xxx
</code></pre>
<p>进行验证。</p>
<h2>为什么需要同步文章？</h2>
<p>Webhook 本身只负责通知：</p>
<blockquote>
<p>有内容变化。</p>
</blockquote>
<p>它不会告诉系统：</p>
<p>哪篇文章变化。</p>
<p>因此：</p>
<p>收到通知后，需要执行同步逻辑：</p>
<pre><code class="language-ts">syncPosts()
</code></pre>
<p>例如：</p>
<p>返回：</p>
<pre><code class="language-ts">;[
  {
    type: 'update',
    slug: 'nextjs-isr',
  },
  {
    type: 'delete',
    slug: 'old-post',
  },
]
</code></pre>
<p>然后：</p>
<p>根据变化精准刷新缓存。</p>
<h2>精准失效，而不是全部刷新</h2>
<p>博客通常包含：</p>
<ul>
<li>首页列表；</li>
<li>标签页面；</li>
<li>分类页面；</li>
<li>单篇文章；</li>
<li>RSS。</li>
</ul>
<p>如果每次：</p>
<pre><code class="language-ts">revalidatePath('/')
</code></pre>
<p>会导致大量页面重新计算。</p>
<p>所以使用：</p>
<p>Tag Cache。</p>
<p>例如：</p>
<pre><code>posts

 ↓

文章列表


post-nextjs-isr

 ↓

单篇文章
</code></pre>
<p>更新：</p>
<pre><code class="language-ts">revalidateTag('post-nextjs-isr')
</code></pre>
<p>只影响对应内容。</p>
<h2>最终架构</h2>
<pre><code>                 ┌──────────────┐
                 │  Pages CMS   │
                 └──────┬───────┘
                        │
                        │ Git Commit
                        ↓

                 ┌──────────────┐
                 │ GitHub Action│
                 └──────┬───────┘
                        │
                        │ syncPosts()
                        ↓

                 ┌──────────────┐
                 │  Database    │
                 └──────┬───────┘
                        │
                        │ Revalidate API
                        ↓

                 ┌──────────────┐
                 │   Next.js    │
                 └──────┬───────┘
                        │

                  revalidateTag()

                        ↓

                 ┌──────────────┐
                 │ ISR Cache    │
                 └──────┬───────┘

                        ↓

                 最新博客页面
</code></pre>
<h1>总结</h1>
<p>这次踩坑让我重新理解了 Serverless 平台的运行机制。</p>
<p>ISR 解决：</p>
<blockquote>
<p>如何让静态页面拥有动态更新能力。</p>
</blockquote>
<p>Webhook 解决：</p>
<blockquote>
<p>如何让系统知道什么时候需要更新。</p>
</blockquote>
<p>GitHub Actions 解决：</p>
<blockquote>
<p>如何获取最新代码并执行同步任务。</p>
</blockquote>
<p>最终：</p>
<ul>
<li>CMS 负责内容管理；</li>
<li>GitHub 保存内容版本；</li>
<li>Actions 执行同步任务；</li>
<li>Next.js 管理缓存；</li>
<li>ISR 提供高速访问。</li>
</ul>
<p>形成了一套：</p>
<p><strong>自动化、高性能、低成本的现代博客架构。</strong></p>
<p>对于个人博客、文档站、知识库这类内容驱动型网站来说：</p>
<p>ISR + Webhook + CI Workflow</p>
<p>是一套非常优雅的解决方案。</p>
<p>它让我可以像使用动态网站一样维护博客，同时享受到静态网站接近 CDN 的访问速度。</p>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>ISR</category>
            <category>Next.js</category>
            <category>博客</category>
            <category>Webhooks</category>
            <enclosure url="https://blog.huanment.top/images/posts/blog-isr.avif" length="0" type="image/avif"/>
        </item>
        <item>
            <title><![CDATA[Graph Agent = 状态机：Agent Workflow 离不开状态机]]></title>
            <link>https://blog.huanment.top/posts/graph-agents</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/graph-agents</guid>
            <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[深入探讨为什么Graph Agent 本质上就是状态机，以及状态机在 Agent Workflow 中的核心地位]]></description>
            <content:encoded><![CDATA[<h1>Graph Agent = 状态机：Agent Workflow 离不开状态机</h1>
<h2>前言</h2>
<p>在 AI Agent 领域，我们经常听到一个概念：<strong>Graph Agent</strong>。</p>
<p>很多人把它当作一种新的架构范式，仿佛它是从某个前沿实验室里冒出来的全新思想。</p>
<p>但如果你仔细审视 Graph Agent 的本质，你会发现一个令人惊讶的事实：</p>
<blockquote>
<p><strong>Graph Agent 就是状态机（State Machine）。</strong></p>
</blockquote>
<p>这不是类比，不是简化，而是本质上它们是同一种东西。</p>
<p>理解这一点，对于设计和实现 Agent Workflow 至关重要。因为状态机是一个拥有数十年历史、经过无数系统验证的成熟理论，而 Agent Workflow 中几乎所有关键问题——流程控制、错误恢复、并发管理、可观测性——状态机早已给出了优雅的答案。</p>
<h2>什么是状态机</h2>
<p>在深入 Agent 之前，让我们先回顾状态机的基本概念。</p>
<p>一个有限状态机（Finite State Machine, FSM）由以下要素组成：</p>
<ul>
<li><strong>状态集合（States）</strong>：系统可以处于的所有可能状态</li>
<li><strong>输入/事件（Events）</strong>：触发状态转移的外部信号</li>
<li><strong>转移函数（Transition Function）</strong>：给定当前状态和输入，决定下一个状态</li>
<li><strong>初始状态（Initial State）</strong>：系统启动时的状态</li>
<li><strong>终止状态（Final States）</strong>：可选的结束状态</li>
</ul>
<p>用一个简单的例子来说明：一个交通信号灯就是一个状态机。</p>
<pre><code class="language-mermaid">stateDiagram-v2
    [*] --> 红灯
    红灯 --> 绿灯: 定时器触发
    绿灯 --> 黄灯: 定时器触发
    黄灯 --> 红灯: 定时器触发
</code></pre>
<p>状态机的核心思想是：<strong>任何时刻，系统都处于一个确定的状态；当收到输入时，系统根据转移规则跳转到下一个状态。</strong></p>
<p>这个模型虽然简单，但它具有极强的表达能力。从编译器的词法分析器，到网络协议的状态管理，再到游戏 AI 的行为控制，状态机无处不在。</p>
<h2>什么是 Graph Agent</h2>
<p>当我们说 Graph Agent 时，我们通常指的是这样一种 Agent 架构：</p>
<p>Agent 的执行流程被建模为一个有向图（Graph），其中：</p>
<ul>
<li><strong>节点（Node）</strong> 代表 Agent 的某个处理步骤或能力单元</li>
<li><strong>边（Edge）</strong> 代表步骤之间的执行顺序和条件分支</li>
<li><strong>状态（State）</strong> 在节点之间传递，每个节点可以读取、修改或生成新的状态</li>
</ul>
<p>典型的 Graph Agent 框架（如 LangGraph）会定义这样的结构：</p>
<pre><code class="language-mermaid">flowchart LR
    Start[ 开始] --> Plan[规划]
    Plan --> Tool[工具调用]
    Tool --> Decision{结果判断}
    Decision -->|成功| Output[输出]
    Decision -->|失败| Plan
    Output --> End[结束]
</code></pre>
<p>在这个图中：</p>
<ul>
<li>每个节点（Plan、Tool、Output）是一个处理单元</li>
<li>边定义了执行流向</li>
<li>状态（当前的对话历史、中间结果、错误信息等）在整个图中流动</li>
</ul>
<h2>为什么说 Graph Agent = 状态机</h2>
<p>让我们将 Graph Agent 与状态机进行严格对比：</p>
<table>
<thead>
<tr>
<th>状态机概念</th>
<th>Graph Agent 对应</th>
</tr>
</thead>
<tbody>
<tr>
<td>状态集合</td>
<td>Agent 的所有可能执行阶段</td>
</tr>
<tr>
<td>事件/输入</td>
<td>LLM 的输出、工具返回值、用户输入</td>
</tr>
<tr>
<td>转移函数</td>
<td>路由逻辑（根据 LLM 输出决定下一步）</td>
</tr>
<tr>
<td>当前状态</td>
<td>当前所在的节点 + 上下文信息</td>
</tr>
<tr>
<td>初始状态</td>
<td>Agent 的启动节点</td>
</tr>
</tbody>
</table>
<p><strong>每一个节点就是一个状态，每一次转移就是一次状态跳转。</strong></p>
<p>这不是牵强的类比，而是精确的等价关系。</p>
<p>当你在 LangGraph 中定义一个图时：</p>
<pre><code class="language-python">from langgraph.graph import StateGraph

graph = StateGraph(AgentState)
graph.add_node('plan', plan_node)
graph.add_node('execute', execute_node)
graph.add_node('output', output_node)

graph.add_edge('plan', 'execute')
graph.add_conditional_edges('execute', should_continue)
</code></pre>
<p>你实际上在做的事情，和用 Statechart 或 XState 定义一个状态机完全一样：</p>
<pre><code class="language-typescript">const agentMachine = createMachine({
  id: 'agent',
  initial: 'plan',
  states: {
    plan: { on: { NEXT: 'execute' } },
    execute: {
      on: {
        SUCCESS: 'output',
        FAILURE: 'plan',
      },
    },
    output: { type: 'final' },
  },
})
</code></pre>
<p>两者的本质完全一致。</p>
<h2>Agent Workflow 为什么离不开状态机</h2>
<p>理解了 Graph Agent 的状态机本质之后，我们来看 Agent Workflow 中几个核心问题，以及状态机如何天然地解决它们。</p>
<h3>1. 流程的可预测性</h3>
<p>Agent 最大的挑战之一是<strong>不可预测性</strong>。</p>
<p>LLM 的输出是概率性的，你无法保证它每次都走同一条路径。</p>
<p>但如果我们将 Agent 建模为状态机，虽然单次执行的路径可能不同，但<strong>所有可能的路径都是预先定义好的</strong>。</p>
<p>这意味着：</p>
<ul>
<li>你可以在运行前分析所有可能的执行路径</li>
<li>你可以对特定路径设置超时、重试、熔断等策略</li>
<li>你可以在监控面板上清晰地看到 Agent 当前处于哪个状态</li>
</ul>
<p>状态机给了不确定的 LLM 一个确定的框架。</p>
<h3>2. 错误恢复</h3>
<p>在实际生产中，Agent 经常会遇到各种失败：</p>
<ul>
<li>API 调用超时</li>
<li>工具返回错误</li>
<li>LLM 输出格式不正确</li>
<li>上下文超出 token 限制</li>
</ul>
<p>如果没有状态机，错误处理往往是这样的：</p>
<pre><code class="language-python">try:
    result = llm.call(messages)
    tool_result = tool.execute(result)
except Exception as e:
    # 该怎么办？重试？回退？终止？
    pass
</code></pre>
<p>而状态机提供了一种结构化的错误处理方式：</p>
<pre><code class="language-mermaid">stateDiagram-v2
    [*] --> 执行中
    执行中 --> 成功: 工具返回结果
    执行中 --> 失败: 工具报错
    执行中 --> 超时: 超过时间限制
    失败 --> 重试: 重试次数 &#x3C; 3
    重试 --> 执行中: 重新执行
    失败 --> 回退: 重试次数 >= 3
    超时 --> 回退
    回退 --> [*]: 终止或通知用户
    成功 --> [*]
</code></pre>
<p>每种错误都有明确的状态转移路径，不会出现「不知道该怎么办」的情况。</p>
<h3>3. 并发与并行</h3>
<p>复杂的工作流经常需要并行执行多个任务。</p>
<p>状态机的扩展——状态图（Statechart）——原生支持并行状态：</p>
<pre><code class="language-mermaid">stateDiagram-v2

    state "数据收集" as DataCollection {
        [*] --> 收集用户数据
        收集用户数据 --> 收集环境数据
        收集环境数据 --> [*]
    }

    state "日志记录" as Logging {
        [*] --> 记录请求
        记录请求 --> 记录结果
        记录结果 --> [*]
    }

    [*] --> DataCollection
    [*] --> Logging

    DataCollection --> 汇总
    Logging --> 汇总
    汇总 --> [*]
</code></pre>
<p>这意味着 Agent 可以同时执行工具调用和日志记录，而不需要复杂的线程管理代码。</p>
<h3>4. 可观测性</h3>
<p>生产环境中的 Agent 必须是可观测的。</p>
<p>状态机天然提供了：</p>
<ul>
<li><strong>当前状态</strong>：Agent 现在在做什么</li>
<li><strong>状态历史</strong>：Agent 经过了哪些步骤</li>
<li><strong>转移日志</strong>：每次状态变化的原因和时间戳</li>
<li><strong>状态统计</strong>：每个状态的平均停留时间、进入次数</li>
</ul>
<p>这些信息对于调试和优化 Agent Workflow 价值巨大。</p>
<h3>5. 恢复与持久化</h3>
<p>当 Agent 执行到一半需要暂停（例如等待用户确认、等待外部系统响应）时，状态机的当前状态可以被序列化保存。</p>
<p>恢复时，只需要从保存的状态重新加载，Agent 就能从中断的地方继续执行。</p>
<p>这就是为什么 LangGraph 提供了 Checkpointer 机制——它本质上就是状态机的状态持久化。</p>
<h2>实际应用：从状态机视角设计 Agent</h2>
<p>理解了 Agent = 状态机之后，我们在设计 Agent Workflow 时可以采用更系统化的方法：</p>
<p><strong>第一步：定义状态集合</strong></p>
<p>列出 Agent 所有可能处于的阶段：</p>
<ul>
<li>初始化</li>
<li>理解用户意图</li>
<li>制定计划</li>
<li>执行工具</li>
<li>处理结果</li>
<li>生成输出</li>
<li>错误处理</li>
<li>等待输入</li>
</ul>
<p><strong>第二步：定义转移规则</strong></p>
<p>明确每个状态下，可能发生什么事件，以及对应的下一个状态。</p>
<p><strong>第三步：定义守卫条件（Guard Conditions）</strong></p>
<p>对于条件分支，明确转移的触发条件：</p>
<ul>
<li>工具调用成功 → 转移到结果处理</li>
<li>工具调用失败 → 转移到重试或回退</li>
<li>用户输入「取消」 → 转移到终止</li>
</ul>
<p><strong>第四步：定义副作用（Side Effects）</strong></p>
<p>某些状态转移伴随着副作用：</p>
<ul>
<li>进入「执行工具」状态时，记录日志</li>
<li>离开「错误处理」状态时，发送告警</li>
<li>进入「输出」状态时，更新统计计数</li>
</ul>
<p><strong>第五步：模拟与验证</strong></p>
<p>在实现之前，用状态机的分析工具验证：</p>
<ul>
<li>是否有不可达的状态？</li>
<li>是否有死循环？</li>
<li>所有终止状态是否都能到达？</li>
</ul>
<h2>总结</h2>
<p>Agent Workflow 并不是什么全新的领域。</p>
<p>它面对的问题——流程控制、错误恢复、并发管理、状态持久化、可观测性——都是计算机科学中已经被深入研究过的问题。</p>
<p>而状态机，正是解决这些问题的经典工具。</p>
<p>当我们说 Graph Agent 时，我们实际上是在说：<strong>用图来表示的状态机</strong>。</p>
<p>当我们说 Agent Workflow 时，我们实际上是在说：<strong>一个状态机的执行实例</strong>。</p>
<p>下次当你设计一个 Agent 系统时，不妨先画出状态机。你会发现，很多看似复杂的问题，在状态机的框架下都会变得清晰明了。</p>
<p>因为 Agent Workflow 离不开状态机，就像操作系统离不开进程调度，数据库离不开事务管理一样。</p>
<p>这是基础设施，不是可选项。</p>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>Agent</category>
            <category>状态机</category>
            <category>AI</category>
            <category>Graph Agent</category>
            <enclosure url="https://blog.huanment.top/images/posts/graph-agents.avif" length="0" type="image/avif"/>
        </item>
        <item>
            <title><![CDATA[探秘穗积宇宙船的核心架构]]></title>
            <link>https://blog.huanment.top/posts/blog-struct</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/blog-struct</guid>
            <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[介绍穗积宇宙船如何基于 Next.js 构建一套现代化的全栈博客架构]]></description>
            <content:encoded><![CDATA[<h1>探秘穗积宇宙船的核心架构</h1>
<h2>前言</h2>
<p>穗积宇宙船（Suiji Tech）是一个基于 Next.js 构建的全栈博客系统。</p>
<p>与传统博客系统不同，我希望它不仅仅是一个内容展示网站，而是一套结合现代 Web 技术理念的内容平台：拥有优秀的阅读体验、完善的 SEO 能力、类型安全的数据层，以及 Serverless 时代下更简单的部署方式。</p>
<p>目前，穗积宇宙船具备以下特点：</p>
<ul>
<li>
<p><strong>响应式设计</strong></p>
<p>博客针对桌面端、平板端以及移动端进行了适配。无论用户通过什么设备访问，都可以获得一致且舒适的阅读体验。</p>
</li>
<li>
<p><strong>现代化 SEO 方案</strong></p>
<p>基于 Next.js 提供的 Metadata API、静态生成（SSG）以及增量静态生成（ISR）能力，让页面能够被搜索引擎高效抓取，同时保持良好的访问性能。</p>
</li>
<li>
<p><strong>灵活的内容管理</strong></p>
<p>文章内容采用 Markdown / MDX 编写，通过文件系统管理内容，同时结合数据库保存结构化数据，让内容创作和业务数据保持解耦。</p>
</li>
<li>
<p><strong>Serverless 架构</strong></p>
<p>项目部署于 Vercel 平台，利用 Serverless Function 和边缘网络能力，无需维护传统服务器，将更多精力集中在功能开发和内容创作上。</p>
</li>
</ul>
<h2>核心架构</h2>
<pre><code class="language-mermaid">flowchart LR

subgraph Client["客户端"]
Browser["浏览器"]
end

subgraph Next["Next.js"]
Page["RSC 页面"]
Action["Server Action"]
Route["Route Handler"]
SEO["Metadata"]
Cache["Cache Components"]
end

subgraph Content["内容系统"]
MDX["Markdown / MDX"]
DB["PostgreSQL"]
end

subgraph ORM["数据访问"]
Drizzle["Drizzle ORM"]
DTO["DTO"]
end

subgraph Deploy["部署"]
Vercel["Vercel Serverless"]
end

Browser --> Page

Page --> Action
Page --> SEO

Action --> Cache
Action --> Drizzle

Route --> Drizzle

Drizzle --> DB

Action --> MDX

Drizzle --> DTO
DTO --> Page

Vercel --> Next
</code></pre>
<p>穗积宇宙船基于 Next.js App Router 构建，充分利用 React Server Components 提供的 Client / Server 分层能力，将应用划分为数据层、业务层以及展示层。</p>
<p>虽然 Next.js 并不是传统意义上的 MVC 框架，但通过合理的工程组织，可以实现类似 MVC 的职责分离。</p>
<h3>Model 层：类型安全的数据访问</h3>
<p>在数据层，我选择了 <strong>Drizzle ORM</strong> 作为 PostgreSQL 的 ORM 方案。</p>
<p>相比传统 ORM，Drizzle 更接近 SQL，同时拥有优秀的 TypeScript 类型推导能力。</p>
<p>通过 Drizzle，我可以直接根据 Schema 获得完整类型：</p>
<ul>
<li>数据库结构与 TypeScript 类型保持同步；</li>
<li>查询结果自动推导；</li>
<li>减少手写 SQL 带来的维护成本。</li>
</ul>
<p>例如：</p>
<pre><code class="language-typescript">const posts = await db.select().from(postsTable)
</code></pre>
<p>返回的数据类型会自动根据数据库 Schema 推导。</p>
<p>不过，数据库实体并不应该直接暴露给前端。</p>
<p>例如：</p>
<ul>
<li>用户密码等敏感字段；</li>
<li>数据库内部字段；</li>
<li>Date 等需要序列化的数据类型。</li>
</ul>
<p>因此，我引入 DTO（Data Transfer Object）层，对数据进行转换和裁剪。</p>
<p>例如：</p>
<pre><code class="language-typescript">export type Dtoify&#x3C;T> = {
  [K in keyof T]: T[K] extends Date
    ? string
    : T[K] extends string | null
      ? string
      : T[K]
}

type UserDTO = Dtoify&#x3C;Omit&#x3C;UserEntity, 'password'>>
</code></pre>
<p>经过 DTO 转换后，前端获得的数据会更加安全，也更加符合展示层需求。</p>
<h3>Controller 层：Server Action 驱动的业务入口</h3>
<p>在传统全栈应用中，Controller 通常负责接收 HTTP 请求，再调用 Service 完成业务逻辑。</p>
<p>但在 Next.js App Router 架构中，我没有选择传统 REST API 的方式，而是采用 Server Action 作为服务端业务入口。</p>
<p>相比 API Route，Server Action 有几个优势：</p>
<ul>
<li>不需要额外暴露 HTTP Endpoint；</li>
<li>前后端共享 TypeScript 类型；</li>
<li>可以直接调用服务端代码；</li>
<li>更容易结合 Next.js 的缓存系统。</li>
</ul>
<p>例如：</p>
<pre><code class="language-tsx">export async function findPosts() {
  'use cache'

  cacheTag('posts')
  cacheLife('weeks')

  return await getPosts()
}

export default async function PostsPage() {
  const posts = await findPosts()

  return &#x3C;>...&#x3C;/>
}
</code></pre>
<p>通过 Server Action，我可以直接在服务端完成数据库查询，并结合 Next.js Cache Components 实现细粒度缓存。</p>
<p>相比传统 RPC 方案，这种方式减少了额外的接口维护成本，同时保持完整的类型安全。</p>
<h2>SEO 优化与 RSS 订阅</h2>
<p>作为一个内容型网站，SEO 是博客系统中非常重要的一环。</p>
<p>穗积宇宙船利用 Next.js 提供的 Metadata API，对页面标题、描述、Open Graph 信息等内容进行动态生成。</p>
<p>例如：</p>
<pre><code class="language-tsx">export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      url: `https://suiji.tech/blog/${params.slug}`,
      images: [...],
    },
  }
}
</code></pre>
<p>这样，每篇文章都会拥有独立的 SEO 信息，同时也能在社交平台分享时展示正确的预览内容。</p>
<h3>Sitemap</h3>
<p>由于博客拥有明确的路由结构，因此我选择在构建阶段生成 sitemap。</p>
<p>使用 <code>next-sitemap</code>，项目在 Build 阶段读取完整路由信息，并生成：</p>
<pre><code class="language-txt">sitemap.xml
</code></pre>
<p>相比运行时生成，这种方式更加简单，同时不会产生额外请求开销。</p>
<h3>RSS Feed</h3>
<p>RSS 与 Sitemap 不同。</p>
<p>Sitemap 只需要记录页面地址，而 RSS 需要包含文章标题、摘要、发布时间，甚至完整正文。</p>
<p>因此，我没有选择构建阶段生成 RSS，而是通过 Route Handler 动态生成：</p>
<pre><code class="language-ts">// app/rss.xml/route.ts

export async function GET() {
  const feed = new Feed({
    title: siteConfig.name,
    description: siteConfig.description,
    id: siteConfig.url,
    link: siteConfig.url,
    language: 'zh-CN',
    copyright: siteConfig.copyright,
    updated: new Date(),
    author: {
      name: siteConfig.name,
    },
  })

  const posts = await findPosts()

  // Add posts to feed

  return new Response(feed.rss2(), {
    headers: {
      'Content-Type': 'application/rss+xml; charset=utf-8',
    },
  })
}
</code></pre>
<p>通过 Route Handler，可以保证 RSS 内容始终与最新文章保持同步。</p>
<h2>未来展望</h2>
<p>穗积宇宙船的目标并不是简单复刻一个博客程序，而是探索一种更加现代化的博客架构：</p>
<ul>
<li>Serverless 部署；</li>
<li>全 TypeScript 技术栈；</li>
<li>类型安全的数据流；</li>
<li>文件内容与数据库数据分离；</li>
<li>基于 Next.js 的全栈开发模式。</li>
</ul>
<p>目前，市面上大部分成熟博客系统仍然依赖传统服务器部署，例如 Halo 虽然功能完善，但仍需要维护后端服务。</p>
<p>而 Serverless 架构的博客系统仍然相对较少。</p>
<p>未来，如果能够进一步抽离项目中的硬编码配置，并完善插件化能力，我计划将穗积宇宙船整理为一个开源博客框架，让更多开发者能够快速搭建属于自己的现代化博客。</p>
<p>敬请期待。</p>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>Next.js</category>
            <category>博客</category>
            <enclosure url="https://blog.huanment.top/images/posts/blog-struct.avif" length="0" type="image/avif"/>
        </item>
        <item>
            <title><![CDATA[深入理解React Server Components]]></title>
            <link>https://blog.huanment.top/posts/react-server-components-deep-dive</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/react-server-components-deep-dive</guid>
            <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[深入探讨RSC的工作原理、优势以及最佳实践]]></description>
            <content:encoded><![CDATA[<h1>深入理解React Server Components</h1>
<p>React Server Components (RSC) 是React生态系统中最具革命性的特性之一。它彻底改变了我们构建React应用的方式，将服务器端渲染提升到了一个全新的层次。在这篇文章中，我将深入探讨RSC的工作原理、优势以及最佳实践。</p>
<h2>什么是React Server Components？</h2>
<p>React Server Components是一种新的组件类型，它们在服务器上运行，并将渲染结果发送到客户端。与传统的服务器端渲染（SSR）不同，RSC不需要在客户端重新执行组件逻辑，这带来了显著的性能提升.</p>
<pre><code class="language-tsx">// 这是一个Server Component
async function BlogPost({ id }) {
  // 直接在组件中获取数据
  const post = await fetchPost(id)

  return (
    &#x3C;article>
      &#x3C;h1>{post.title}&#x3C;/h1>
      &#x3C;p>{post.content}&#x3C;/p>
    &#x3C;/article>
  )
}
</code></pre>
<h2>RSC的工作原理</h2>
<p>RSC的工作原理基于JavaScript的模块化系统。当你在服务器上导入一个RSC时，React会将其编译为JavaScript代码，并将其发送到客户端。在客户端，React会使用这些代码来渲染组件，就像普通的React组件一样。</p>
<p>RSC的一个重要特性是它们可以在服务器上获取数据。这意味着你可以在组件中直接调用API，并将结果作为props传递给子组件。这消除了在客户端重新获取数据的需要，从而提高了性能。</p>
<h2>RSC的优势</h2>
<p>RSC提供了许多优势，包括：</p>
<ul>
<li><strong>性能提升</strong>：由于RSC在服务器上运行，它们不需要在客户端重新执行组件逻辑，从而减少了客户端的负载。</li>
<li><strong>更好的SEO</strong>：由于RSC在服务器上渲染，它们可以提供更好的SEO性能。</li>
<li><strong>更好的可维护性</strong>：由于RSC在服务器上运行，它们可以更容易地与现有的服务器端代码集成。</li>
</ul>
<h2>最佳实践</h2>
<p>虽然RSC提供了许多优势，但也有一些最佳实践需要遵循，以确保你的应用能够充分利用RSC：</p>
<ul>
<li><strong>避免在RSC中直接使用客户端API</strong>：由于RSC在服务器上运行，它们无法直接访问客户端API。如果你需要在RSC中使用客户端API，你应该在客户端组件中调用这些API，并将结果作为props传递给RSC。</li>
<li><strong>使用RSC来渲染静态内容</strong>：RSC非常适合渲染静态内容，如博客文章或产品页面。如果你需要渲染动态内容，你应该使用客户端组件。</li>
<li><strong>使用RSC来优化性能</strong>：RSC可以显著提高性能，特别是对于大型应用。你应该使用RSC来优化性能关键的部分，如数据获取或复杂的计算。</li>
</ul>
<h2>结论</h2>
<p>React Server Components是一种革命性的特性，它彻底改变了我们构建React应用的方式。通过在服务器上运行组件，RSC可以提供更好的性能、更好的SEO和更好的可维护性。然而，也有一些最佳实践需要遵循，以确保你的应用能够充分利用RSC。如果你正在构建一个大型应用，你应该考虑使用RSC来优化性能。</p>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>React</category>
            <category>RSC</category>
            <category>Next.js</category>
            <enclosure url="https://blog.huanment.top/images/posts/react-server-components-deep-dive.avif" length="0" type="image/avif"/>
        </item>
        <item>
            <title><![CDATA[为什么我的博客选择了 Next.js]]></title>
            <link>https://blog.huanment.top/posts/why-nextjs</link>
            <guid isPermaLink="false">https://blog.huanment.top/posts/why-nextjs</guid>
            <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[简单谈谈为什么我的博客选择了 Next.js 作为框架]]></description>
            <content:encoded><![CDATA[<h1>为什么我的博客选择了 Next.js</h1>
<h2>前言</h2>
<p>我是一个全栈程序员，已经尝试过各种框架来制作我的博客，包括但不限于 VitePress、Halo、Astro 等。最终，我还是选择了我最熟悉的 Next.js。</p>
<p>这并不是因为它是「最好的博客框架」，而是因为它最符合我的需求。</p>
<p>我的博客不仅仅用来写文章，它还是我的技术实验室。我会在这里尝试新的动画、新的交互、新的数据库架构、新的 AI 能力，甚至会把它当作一个完整的 Web 应用来开发。</p>
<p>因此，我需要的并不是一个优秀的静态博客生成器，而是一个拥有完整生态、足够灵活且几乎没有上限的全栈框架。</p>
<pre><code class="language-tsx">#!/app/page.tsx
export default function RootPage() {
  return &#x3C;>Hello Next.js🎉&#x3C;/>
}
</code></pre>
<h2>我排除的选项</h2>
<h3>为什么不是 0 代码的 Halo</h3>
<p>Halo 是一个基于 Java 的博客框架，它提供了丰富的功能和完善的后台管理系统。</p>
<p>然而，我并不喜欢 Java。对于一个个人博客而言，为了运行它还需要维护一整套 Java 服务，无论是部署还是后期维护，对我来说都有些过于笨重。</p>
<p>另外，它的主题开发体验也并不理想。</p>
<p>如果只是换个配色倒还好，但如果想彻底重构页面，就需要重新学习 Halo 的模板体系，并继承别人写下的大量 CSS 类名和变量。</p>
<p>最让我难受的是——<strong>它们几乎都不用 Tailwind CSS。</strong></p>
<p>而我现在已经完全习惯了 Utility First 的开发方式，再回到传统 CSS 的开发体验，确实很难适应。</p>
<h3>为什么不是元数据驱动的 VitePress</h3>
<p>VitePress 是一个非常优秀的静态站点生成器。</p>
<p>如果只是写文档，它几乎是我的首选。</p>
<p>它拥有极快的构建速度、优秀的 SEO，以及极佳的 Markdown 支持。</p>
<p>理论上来说，它甚至可以只依赖 Markdown 文件生成完整的网站，不需要任何后端服务。</p>
<p>但是问题也正出现在这里。</p>
<p>我的博客并不是完全静态的。</p>
<p>例如：</p>
<ul>
<li>评论系统</li>
<li>阅读量统计</li>
<li>点赞功能</li>
<li>分享统计</li>
<li>搜索</li>
<li>用户系统</li>
</ul>
<p>这些能力虽然都能够在 VitePress 中实现，但大多需要自行拼装各种插件，或者编写大量客户端代码去请求接口。</p>
<p>随着功能越来越多，它更像是在一个静态网站生成器上不断打补丁。</p>
<p>而我更希望框架本身就能够很好地支持这些动态能力。</p>
<h2>是什么让 Next.js 牢牢吸引了我</h2>
<h3>丰富且现代的渲染能力</h3>
<p>很多人都会说 Next.js 有很多功能。</p>
<p>但真正吸引我的，并不是某一个功能，而是它们能够组合起来。</p>
<p>例如首页的文章列表几乎不会变化，可以直接使用 <strong>SSG</strong>，获得接近纯静态网站的访问速度。</p>
<p>文章详情页又包含评论、阅读量、点赞等动态内容，可以使用 <strong>SSR</strong> 或缓存策略进行渲染。</p>
<p>而像评论区、阅读量这样的局部动态数据，又能够通过 <strong>React Server Components</strong> 与 <strong>Partial Prerendering (PPR)</strong> 进行流式加载。</p>
<p>以前我需要自己思考：</p>
<ul>
<li>哪些页面应该静态生成？</li>
<li>哪些页面需要服务器渲染？</li>
<li>哪些内容客户端请求？</li>
<li>哪些数据应该缓存？</li>
</ul>
<p>现在更多时候，我只需要思考一句话：</p>
<blockquote>
<p><strong>这份数据应该什么时候更新？</strong></p>
</blockquote>
<p>剩下的大多数工作，都可以放心交给 Next.js。</p>
<h3>React Server Components</h3>
<p>我认为这是近年来 React 最大的一次架构升级。</p>
<p>以前写 React，大部分页面都会经历这样的流程：</p>
<p>浏览器下载 JavaScript → Hydration（水合）→ 请求接口 → 再次渲染页面。</p>
<p>而现在，很多事情都可以直接发生在服务器。</p>
<p>例如：</p>
<pre><code class="language-tsx">export default async function Page() {
  const posts = await getPosts()

  return &#x3C;PostList posts={posts} />
}
</code></pre>
<p>整个页面甚至不需要任何客户端 JavaScript。</p>
<p>数据库查询、Markdown 渲染、权限判断等逻辑，都可以直接放在服务端完成。</p>
<p>真正需要交互的时候，再写一个 Client Component 即可。</p>
<p>这种开发体验，让我感觉像是在写传统服务端模板，却拥有 React 的组件化开发方式。</p>
<h3>Partial Prerendering（PPR）</h3>
<p>这是我最喜欢的新特性之一。</p>
<p>博客页面真正动态的内容其实并不多。</p>
<p>例如：</p>
<ul>
<li>阅读量</li>
<li>点赞数量</li>
<li>评论列表</li>
<li>推荐文章</li>
</ul>
<p>如果整个页面 SSR，那么为了几个数字，每次请求都要重新渲染整个页面。</p>
<p>如果全部交给客户端请求，又会看到页面不断出现 Loading、闪烁等体验问题。</p>
<p>PPR 正好解决了这个矛盾。</p>
<p>静态内容立即返回。</p>
<p>动态内容通过 <code>Suspense</code> 以流式方式发送。</p>
<p>用户既能快速看到页面，又能够在后台完成动态数据加载。</p>
<p>这种体验非常自然，也让我第一次觉得 SSR 与 CSR 并不是非此即彼，而是能够真正结合起来。</p>
<h3>极其灵活的生态</h3>
<p>这一点，几乎是我最终选择 Next.js 最重要的原因。</p>
<p>很多博客框架都会告诉你：</p>
<blockquote>
<p>「推荐这样开发。」</p>
</blockquote>
<p>如果你的需求刚好符合，它们会很好用。</p>
<p>但一旦需求开始复杂，就会发现很多地方根本改不了。</p>
<p>而 Next.js 更像是一块乐高积木。</p>
<p>我可以自由组合自己喜欢的任何技术。</p>
<p>例如我的博客目前已经集成或计划集成：</p>
<ul>
<li>Tailwind CSS</li>
<li>Motion</li>
<li>MDX</li>
<li>Drizzle ORM</li>
<li>PostgreSQL</li>
<li>Hono</li>
<li>Better Auth</li>
<li>next-intl</li>
<li>Algolia Search</li>
<li>React Three Fiber</li>
<li>GSAP</li>
</ul>
<p>未来如果我想加入：</p>
<ul>
<li>AI 对话</li>
<li>在线代码运行</li>
<li>WebAssembly</li>
<li>WebGPU</li>
<li>3D 场景展示</li>
</ul>
<p>几乎都不需要迁移框架。</p>
<p>它不会限制我的想法，而是能够承载我的各种尝试。</p>
<h3>灵活的客户端组件</h3>
<p>很多人觉得 Server Component 会让 React 变复杂。</p>
<p>但我反而觉得，它让组件职责变得更加清晰。</p>
<p>服务器负责：</p>
<ul>
<li>查询数据库</li>
<li>Markdown 渲染</li>
<li>权限验证</li>
<li>SEO</li>
</ul>
<p>客户端负责：</p>
<ul>
<li>动画</li>
<li>点击事件</li>
<li>表单</li>
<li>状态管理</li>
</ul>
<p>例如：</p>
<pre><code class="language-tsx">'use client'

export function LikeButton() {
  const [liked, setLiked] = useState(false)

  // ...
}
</code></pre>
<p>只需要一句 <code>'use client'</code>。</p>
<p>剩下的事情，全部交给 Next.js。</p>
<p>相比以前整个页面都运行在客户端，我觉得这种方式更加自然，也更加符合 Web 本来的运行模式。</p>
<h3>对 AI 开发极其友好</h3>
<p>随着 AI 编程越来越成熟，我越来越觉得，一个框架是否适合 AI，也成为了重要的衡量标准。</p>
<p>而 Next.js 无疑是目前 AI 生态最完善的前端框架之一。</p>
<p>官方不仅提供了：</p>
<ul>
<li>MCP</li>
<li>Agents.md</li>
<li>AI SDK</li>
<li>v0</li>
<li>丰富的模板与最佳实践</li>
</ul>
<p>几乎所有主流 AI Coding Agent，也都优先支持 Next.js。</p>
<p>很多以前需要查半天文档才能完成的功能，现在一句 Prompt 就能够生成完整的页面。</p>
<p>AI 已经成为开发流程的一部分，而 Next.js 则是目前与 AI 协作体验最好的框架之一。</p>
<h2>总结</h2>
<p>我并不认为 Next.js 是所有博客最好的选择。</p>
<p>如果只是写一份文档，我依然会推荐 VitePress。</p>
<p>如果只是个人记录，Astro、Eleventy 都是非常优秀的静态博客方案。</p>
<p>如果更喜欢开箱即用，Halo、WordPress 等成熟 CMS 依旧拥有庞大的生态。</p>
<p>但是对于我来说，博客从来都不仅仅是一堆 Markdown。</p>
<p>它还是我的实验场。</p>
<p>我会不断尝试新的动画、新的交互、新的数据库架构、新的 AI 能力，甚至把它当作新的技术试验田。</p>
<p>而 Next.js 几乎不会因为我的想法而限制我。</p>
<p>它可以是一份文档站，也可以是一套博客系统；可以是一个作品集，也可以成长为一个完整的平台。</p>
<p>正因如此，在尝试了许多框架之后，我最终还是回到了 Next.js。</p>
<p>因为它不仅仅是一个博客框架，更是一个能够承载我所有想法的平台。</p>]]></content:encoded>
            <author>HM-Suiji</author>
            <category>Next.js</category>
            <category>博客</category>
            <enclosure url="https://blog.huanment.top/images/posts/why-nextjs.avif" length="0" type="image/avif"/>
        </item>
    </channel>
</rss>