<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<atom:link href="https://liuyaowen.cn/feed" rel="self" type="application/rss+xml"/>
<title>刘耀文</title>
<link>https://liuyaowen.cn</link>
<description>刘耀文个人网站，聚焦技术分享、项目展示与成长记录，涵盖前端开发、人工智能、个人作品集等内容，致力于打造专业、有温度的开发者主页。</description>
<language>zh-CN</language>
<copyright>© 刘耀文 </copyright>
<pubDate>Wed, 10 Jun 2026 15:39:49 GMT</pubDate>
<generator>Mix Space CMS (https://github.com/mx-space)</generator>
<docs>https://mx-space.js.org</docs>
<image>
    <url>https://avatars.githubusercontent.com/u/55525531?v=4</url>
    <title>刘耀文</title>
    <link>https://liuyaowen.cn</link>
</image>
<item>
    <title>为什么 Vite、Webpack、Rspack 能实现 @ 别名</title>
    <link>https://liuyaowen.cn/posts/default/vite-webpack-rspack</link>
    <pubDate>Tue, 09 Jun 2026 11:39:13 GMT</pubDate>
    <description>Resolver 是什么?

对于：

import Button from &quot;@/componen</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/vite-webpack-rspack'>https://liuyaowen.cn/posts/default/vite-webpack-rspack</a></blockquote>
          <h2>Resolver 是什么?</h2>
<p>对于：</p>
<pre><code class="language-ts">import Button from "@/components/Button";</code></pre><p>JavaScript 引擎实际上只看到了 这些：</p>
<pre><code class="language-text">specifier = "@/components/Button"</code></pre><p>接下来需要解决的问题是：</p>
<pre><code class="language-text">specifier
    ↓
真实模块</code></pre><p>这个过程称为 Resolution。</p>
<p>Node.js ESM 规范中的入口算法：</p>
<pre><code class="language-text">ESM_RESOLVE(specifier, parentURL)</code></pre><p>本质就是完成这个映射过程。</p>
<h2>Node 默认支持哪些 specifier</h2>
<p>Node 原生 Resolver 只支持四类：</p>
<pre><code class="language-js">import "./foo.js"
import "../foo.js"
import "/app/foo.js"</code></pre><p>路径模块。</p>
<pre><code class="language-js">import react from "react"</code></pre><p>包模块。</p>
<pre><code class="language-js">import db from "#db"</code></pre><p>Package Imports。</p>
<pre><code class="language-js">import x from "file:///app/a.js"</code></pre><p>URL 模块。</p>
<p>除此之外：</p>
<pre><code class="language-js">import x from "@/utils"</code></pre><p>Node 无法解析。</p>
<p>直接进入：</p>
<pre><code class="language-text">Module Not Found</code></pre><p>异常路径。</p>
<h2>为什么 Alias 可以工作</h2>
<p>假设存在：</p>
<pre><code class="language-js">import Button from "@/components/Button"</code></pre><p>Webpack 配置：</p>
<pre><code class="language-js">resolve: {
  alias: {
    "@": "/project/src"
  }
}</code></pre><p>Webpack 在执行真正解析前：</p>
<pre><code class="language-text">@/components/Button</code></pre><p>先经过 Alias Resolver：</p>
<pre><code class="language-text">/project/src/components/Button</code></pre><p>随后再进入正常模块解析。</p>
<p>因此 Alias 并不是一种新的模块类型。</p>
<p>而是：</p>
<pre><code class="language-text">旧 specifier
    ↓
新 specifier</code></pre><p>的一次转换。</p>
<h2>Vite Alias</h2>
<p>Vite：</p>
<pre><code class="language-ts">resolve: {
  alias: {
    "@": path.resolve(__dirname, "src")
  }
}</code></pre><p>执行流程与 Webpack 完全一致。</p>
<p>对于：</p>
<pre><code class="language-ts">import x from "@/utils/request"</code></pre><p>Vite 首先执行：</p>
<pre><code class="language-text">@ -&gt; /project/src</code></pre><p>得到：</p>
<pre><code class="language-text">/project/src/utils/request</code></pre><p>然后继续执行后续解析。</p>
<p>因此：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias</code></pre><p>本质是同一个东西。</p>
<p>区别只是 Resolver 实现不同。</p>
<h2>TypeScript Paths 为什么经常失效</h2>
<p>配置：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}</code></pre><p>很多人会发现：</p>
<pre><code class="language-text">VSCode 正常
TypeScript 正常
Node 运行失败</code></pre><p>原因是：</p>
<pre><code class="language-text">paths</code></pre><p>不是运行时 Resolver。</p>
<p>TypeScript 只会在：</p>
<pre><code class="language-text">类型检查
IDE 跳转
编译阶段</code></pre><p>使用 paths。</p>
<p>Node 运行时根本不会读取：</p>
<pre><code class="language-json">tsconfig.json</code></pre><p>因此：</p>
<pre><code class="language-json">{
  "paths": {}
}</code></pre><p>无法让 Node 识别：</p>
<pre><code class="language-js">import "@/utils"</code></pre><p>必须额外配置：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias
Node Loader</code></pre><p>其中之一。</p>
<h2>Node Loader 如何实现 Alias</h2>
<p>Node 提供：</p>
<pre><code class="language-js">export async function resolve(
  specifier,
  context,
  nextResolve
)</code></pre><p>允许接管解析过程。</p>
<p>例如：</p>
<pre><code class="language-js">export async function resolve(
  specifier,
  context,
  nextResolve
) {
  if (specifier.startsWith("@/")) {
    return {
      url: pathToFileURL(
        path.resolve(
          process.cwd(),
          "src",
          specifier.slice(2)
        )
      ).href,
      shortCircuit: true
    }
  }

  return nextResolve(specifier, context)
}</code></pre><p>此时：</p>
<pre><code class="language-js">import x from "@/utils"</code></pre><p>会变成：</p>
<pre><code class="language-text">file:///project/src/utils.js</code></pre><p>然后进入正常加载流程。</p>
<h2>Browser Import Map</h2>
<p>浏览器没有：</p>
<pre><code class="language-text">node_modules</code></pre><p>因此：</p>
<pre><code class="language-js">import React from "react"</code></pre><p>无法工作。</p>
<p>Import Map 的作用：</p>
<pre><code class="language-html">&lt;script type="importmap"&gt;
{
  "imports": {
    "react": "/vendor/react.js"
  }
}
&lt;/script&gt;</code></pre><p>执行：</p>
<pre><code class="language-js">import React from "react"</code></pre><p>时。</p>
<p>浏览器先映射：</p>
<pre><code class="language-text">react
    ↓
/vendor/react.js</code></pre><p>再继续加载。</p>
<h2>Resolver 的本质</h2>
<p>观察：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias
TS Paths
Node Loader
Import Map</code></pre><p>会发现它们都在做同一件事：</p>
<pre><code class="language-text">specifier
    ↓
mapping
    ↓
new specifier
    ↓
real URL</code></pre><p>区别仅在于：</p>
<pre><code class="language-text">映射规则存放的位置不同</code></pre><h2>模块图的起点</h2>
<p>对于：</p>
<pre><code class="language-ts">import App from "./App"</code></pre><p>编译器首先执行：</p>
<pre><code class="language-text">specifier
    ↓
resolver
    ↓
real module</code></pre><p>得到：</p>
<pre><code class="language-text">App.tsx</code></pre><p>然后继续递归：</p>
<pre><code class="language-text">App.tsx
    ↓
Button.tsx
    ↓
request.ts
    ↓
...</code></pre><p>最终构建：</p>
<pre><code class="language-text">Module Graph</code></pre><p>Tree Shaking、Code Splitting、HMR、Chunk 拆分全部建立在这个图之上。</p>
<p>因此 Resolver 是整个现代前端工具链真正的入口。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/vite-webpack-rspack#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233523</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Node.js ESM 模块解析算法理解</title>
    <link>https://liuyaowen.cn/posts/default/node-js-esm</link>
    <pubDate>Tue, 09 Jun 2026 11:36:56 GMT</pubDate>
    <description>Node.js ESM 解析算法（Resolution Algorithm）的职责只有两个：

将 </description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/node-js-esm'>https://liuyaowen.cn/posts/default/node-js-esm</a></blockquote>
          <p>Node.js ESM 解析算法（Resolution Algorithm）的职责只有两个：</p>
<ol>
<li>将 <code>import</code> 中的 specifier 解析为最终 URL</li>
<li>判断该 URL 对应的模块格式</li>
</ol>
<p>例如：</p>
<pre><code class="language-js">import react from "react";
import util from "./utils.js";
import config from "#config";</code></pre><p>Node.js 最终需要得到：</p>
<pre><code class="language-text">react      -&gt; file:///project/node_modules/react/index.js
./utils.js -&gt; file:///project/src/utils.js
#config    -&gt; file:///project/src/config/index.js</code></pre><p>以及：</p>
<pre><code class="language-text">module
commonjs
json
wasm</code></pre><p>等模块格式。</p>
<p>整个 ESM 规范本质上是在描述：</p>
<pre><code class="language-text">specifier
    ↓
resolved URL
    ↓
module format
    ↓
load
    ↓
execute</code></pre><h1>一、ESM_RESOLVE 总体流程</h1>
<p>Node.js ESM 的入口算法：</p>
<pre><code class="language-text">ESM_RESOLVE(specifier, parentURL)</code></pre><p>可以简化为：</p>
<pre><code class="language-text">判断 specifier 类型

URL
路径
#imports
裸包名

        ↓

解析得到 URL

        ↓

检查文件是否合法

        ↓

判断模块格式

        ↓

返回给 Loader</code></pre><p>整个算法的核心目标：</p>
<pre><code class="language-text">specifier
      ↓
唯一 URL</code></pre><p>而不是像 CommonJS 那样不断猜测。</p>
<h1>二、specifier 分类</h1>
<p>Node.js 将 specifier 分为四类。</p>
<h2>1. URL Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import x from "file:///app/src/a.js";</code></pre><p>或者：</p>
<pre><code class="language-js">import x from "data:text/javascript,export default 1";</code></pre><p>如果本身是合法 URL：</p>
<pre><code class="language-text">new URL(specifier)</code></pre><p>成功。</p>
<p>那么直接返回。</p>
<h2>2. Relative Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import x from "./foo.js";
import y from "../bar.js";</code></pre><p>Node.js 根据当前模块位置解析。</p>
<p>假设：</p>
<pre><code class="language-text">file:///app/src/main.js</code></pre><p>执行：</p>
<pre><code class="language-js">import "./utils.js";</code></pre><p>得到：</p>
<pre><code class="language-text">file:///app/src/utils.js</code></pre><p>本质上就是：</p>
<pre><code class="language-js">new URL("./utils.js", parentURL)</code></pre><h2>3. Package Imports</h2>
<p>例如：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>或者：</p>
<pre><code class="language-js">import logger from "#utils/logger";</code></pre><p>以：</p>
<pre><code class="language-text">#</code></pre><p>开头。</p>
<p>会进入：</p>
<pre><code class="language-text">PACKAGE_IMPORTS_RESOLVE()</code></pre><p>读取当前包：</p>
<pre><code class="language-json">{
  "imports": {
    "#db": "./src/db/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}</code></pre><p>例如：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>最终得到：</p>
<pre><code class="language-text">./src/db/index.js</code></pre><h2>4. Bare Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import react from "react";</code></pre><pre><code class="language-js">import lodash from "lodash";</code></pre><pre><code class="language-js">import axios from "axios";</code></pre><p>既不是：</p>
<pre><code class="language-text">URL
路径
#import</code></pre><p>就属于裸包名。</p>
<p>进入：</p>
<pre><code class="language-text">PACKAGE_RESOLVE()</code></pre><p>开始查找 node_modules。</p>
<h1>三、PACKAGE_RESOLVE</h1>
<p>这是 Node.js 查找 npm 包的核心逻辑。</p>
<p>例如：</p>
<pre><code class="language-js">import react from "react";</code></pre><p>当前文件：</p>
<pre><code class="language-text">/app/src/pages/home/index.js</code></pre><p>Node.js 会不断向上查找：</p>
<pre><code class="language-text">/app/src/pages/home/node_modules/react
/app/src/pages/node_modules/react
/app/src/node_modules/react
/app/node_modules/react
/node_modules/react</code></pre><p>直到找到。</p>
<p>等价于：</p>
<pre><code class="language-text">while(currentDirectory){
    查找 node_modules/packageName
    找不到继续向上
}</code></pre><h1>四、读取 package.json</h1>
<p>找到包以后：</p>
<pre><code class="language-text">node_modules/react/package.json</code></pre><p>Node.js 开始读取配置。</p>
<p>优先级如下：</p>
<pre><code class="language-text">exports
    ↓
main
    ↓
直接路径</code></pre><h1>五、exports 机制</h1>
<p>现代 Node.js 包解析几乎完全依赖 exports。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js",
    "./jsx-runtime": "./dist/jsx-runtime.js"
  }
}</code></pre><p>允许：</p>
<pre><code class="language-js">import React from "react";</code></pre><p>对应：</p>
<pre><code class="language-text">.</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.js</code></pre><p>允许：</p>
<pre><code class="language-js">import jsx from "react/jsx-runtime";</code></pre><p>对应：</p>
<pre><code class="language-text">./jsx-runtime</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/jsx-runtime.js</code></pre><p>但是：</p>
<pre><code class="language-js">import internal from "react/internal";</code></pre><p>如果 exports 中不存在：</p>
<pre><code class="language-json">"./internal"</code></pre><p>则报错：</p>
<pre><code class="language-text">Package Path Not Exported</code></pre><h1>六、exports 的本质</h1>
<p>exports 可以理解为：</p>
<pre><code class="language-text">包对外暴露的公开 API</code></pre><p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js",
    "./api": "./dist/api.js"
  }
}</code></pre><p>允许：</p>
<pre><code class="language-js">import x from "my-lib";
import y from "my-lib/api";</code></pre><p>禁止：</p>
<pre><code class="language-js">import z from "my-lib/dist/internal.js";</code></pre><p>即使文件真实存在。</p>
<h1>七、Conditional Exports</h1>
<p>exports 不一定是字符串。</p>
<p>可以是对象。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}</code></pre><p>Node.js 会根据条件选择。</p>
<p>ESM：</p>
<pre><code class="language-js">import x from "lib";</code></pre><p>匹配：</p>
<pre><code class="language-json">"import"</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.mjs</code></pre><p>CommonJS：</p>
<pre><code class="language-js">require("lib");</code></pre><p>匹配：</p>
<pre><code class="language-json">"require"</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.cjs</code></pre><h1>八、PACKAGE_SELF_RESOLVE</h1>
<p>假设：</p>
<pre><code class="language-json">{
  "name": "my-lib",
  "exports": {
    ".": "./src/index.js",
    "./core": "./src/core.js"
  }
}</code></pre><p>当前代码就在：</p>
<pre><code class="language-text">my-lib</code></pre><p>内部。</p>
<p>那么：</p>
<pre><code class="language-js">import core from "my-lib/core";</code></pre><p>不会去外部 node_modules 查找。</p>
<p>Node.js 会发现：</p>
<pre><code class="language-text">当前包名就是 my-lib</code></pre><p>直接使用当前 package.json 的 exports。</p>
<p>这就是：</p>
<pre><code class="language-text">PACKAGE_SELF_RESOLVE</code></pre><h1>九、imports 机制</h1>
<p>imports 和 exports 很像。</p>
<p>区别：</p>
<pre><code class="language-text">exports 给别人用
imports 给自己用</code></pre><p>例如：</p>
<pre><code class="language-json">{
  "imports": {
    "#db": "./src/db/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}</code></pre><p>之后：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>实际解析：</p>
<pre><code class="language-text">./src/db/index.js</code></pre><p>再例如：</p>
<pre><code class="language-js">import stringUtil from "#utils/string.js";</code></pre><p>得到：</p>
<pre><code class="language-text">./src/utils/string.js</code></pre><h1>十、Pattern Match</h1>
<p>imports 和 exports 支持通配符。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    "./features/*": "./src/features/*.js"
  }
}</code></pre><p>执行：</p>
<pre><code class="language-js">import user from "pkg/features/user";</code></pre><p>匹配：</p>
<pre><code class="language-text">*</code></pre><p>得到：</p>
<pre><code class="language-text">user</code></pre><p>最终：</p>
<pre><code class="language-text">./src/features/user.js</code></pre><h1>十一、PACKAGE_TARGET_RESOLVE</h1>
<p>这是 exports/imports 真正执行映射的地方。</p>
<p>支持四种类型。</p>
<h2>String</h2>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js"
  }
}</code></pre><p>直接解析。</p>
<h2>Object</h2>
<pre><code class="language-json">{
  "exports": {
    ".": {
      "node": "./node.js",
      "browser": "./browser.js",
      "default": "./index.js"
    }
  }
}</code></pre><p>根据 conditions 选择。</p>
<h2>Array</h2>
<pre><code class="language-json">{
  "exports": {
    ".": [
      "./native.js",
      "./fallback.js"
    ]
  }
}</code></pre><p>前一个失败。</p>
<p>继续尝试下一个。</p>
<h2>null</h2>
<pre><code class="language-json">{
  "exports": {
    "./internal/*": null
  }
}</code></pre><p>明确禁止导出。</p>
<h1>十二、ESM_FILE_FORMAT</h1>
<p>URL 定位完成后。</p>
<p>Node.js 需要判断：</p>
<pre><code class="language-text">这个文件应该按什么格式加载？</code></pre><h2>.mjs</h2>
<pre><code class="language-text">module</code></pre><h2>.cjs</h2>
<pre><code class="language-text">commonjs</code></pre><h2>.json</h2>
<pre><code class="language-text">json</code></pre><h2>.wasm</h2>
<pre><code class="language-text">wasm</code></pre><h2>.node</h2>
<pre><code class="language-text">addon</code></pre><p>原生扩展模块。</p>
<h1>十三、type 字段的作用</h1>
<p>对于：</p>
<pre><code class="language-text">.js</code></pre><p>文件。</p>
<p>Node.js 会查找最近的 package.json。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "type": "module"
}</code></pre><p>那么：</p>
<pre><code class="language-js">app.js</code></pre><p>被解释为：</p>
<pre><code class="language-text">ESM</code></pre><p>如果：</p>
<pre><code class="language-json">{
  "type": "commonjs"
}</code></pre><p>则：</p>
<pre><code class="language-js">app.js</code></pre><p>被解释为：</p>
<pre><code class="language-text">CommonJS</code></pre><p>因此：</p>
<pre><code class="language-text">.mjs</code></pre><p>永远 ESM。</p>
<pre><code class="language-text">.cjs</code></pre><p>永远 CommonJS。</p>
<pre><code class="language-text">.js</code></pre><p>取决于 type。</p>
<h1>十四、为什么 ESM 不支持目录导入</h1>
<p>CommonJS：</p>
<pre><code class="language-js">require("./foo");</code></pre><p>Node.js 会尝试：</p>
<pre><code class="language-text">foo.js
foo.json
foo.node
foo/index.js
foo/index.json</code></pre><p>ESM：</p>
<pre><code class="language-js">import "./foo";</code></pre><p>不会猜。</p>
<p>如果：</p>
<pre><code class="language-text">foo</code></pre><p>是目录。</p>
<p>直接报错：</p>
<pre><code class="language-text">Unsupported Directory Import</code></pre><p>正确写法：</p>
<pre><code class="language-js">import "./foo/index.js";</code></pre><p>或者：</p>
<pre><code class="language-js">import "./foo.js";</code></pre><h1>十五、LOOKUP_PACKAGE_SCOPE</h1>
<p>Node.js 如何找到最近的 package.json？</p>
<p>算法：</p>
<pre><code class="language-text">当前目录
      ↓
父目录
      ↓
继续向上
      ↓
直到根目录</code></pre><p>例如：</p>
<pre><code class="language-text">/app/src/pages/home/index.js</code></pre><p>查找：</p>
<pre><code class="language-text">/app/src/pages/home/package.json
/app/src/pages/package.json
/app/src/package.json
/app/package.json</code></pre><p>找到第一个就停止。</p>
<h1>十六、自定义 ESM Resolver</h1>
<p>Node.js 默认解析：</p>
<pre><code class="language-js">import x from "./a.js";
import y from "react";</code></pre><p>如果想支持：</p>
<pre><code class="language-js">import x from "@/utils";</code></pre><p>怎么办？</p>
<p>答案：</p>
<pre><code class="language-text">Loader Hook</code></pre><p>例如：</p>
<pre><code class="language-js">import { pathToFileURL } from "node:url";
import path from "node:path";

export async function resolve(
  specifier,
  context,
  nextResolve
) {
  if (specifier.startsWith("@/")) {
    return {
      url: pathToFileURL(
        path.resolve(
          process.cwd(),
          "src",
          specifier.slice(2)
        )
      ).href,
      shortCircuit: true
    };
  }

  return nextResolve(specifier, context);
}</code></pre><p>运行：</p>
<pre><code class="language-bash">node --loader ./loader.mjs app.js</code></pre><p>之后：</p>
<pre><code class="language-js">import util from "@/utils.js";</code></pre><p>就会自动映射：</p>
<pre><code class="language-text">src/utils.js</code></pre><h1>十七、完整解析链路</h1>
<p>Node.js ESM 解析可以总结为：</p>
<pre><code class="language-text">import specifier
        ↓
ESM_RESOLVE
        ↓
判断类型

URL
路径
#imports
裸包名

        ↓

PACKAGE_RESOLVE

        ↓

exports
imports
main

        ↓

得到最终 URL

        ↓

ESM_FILE_FORMAT

        ↓

module
commonjs
json
wasm

        ↓

Loader

        ↓

Execute</code></pre><h1>总结</h1>
<p>Node.js ESM 的核心思想不是“寻找文件”。</p>
<p>而是：</p>
<pre><code class="language-text">specifier
      ↓
确定 URL
      ↓
确定模块格式
      ↓
加载执行</code></pre><p>整个 exports、imports、type、conditional exports、loader 机制，本质上都是围绕这一目标构建的。</p>
<p>ESM 解析模型相比 CommonJS 更严格、更静态、更接近浏览器，也更适合现代工具链（Vite、Webpack、Rspack、Rollup、Turbopack）进行分析和优化。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/node-js-esm#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233522</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>从 C++ 到 Rust、Go：把主流编译流程真正串起来</title>
    <link>https://liuyaowen.cn/posts/default/c-rust-go</link>
    <pubDate>Mon, 08 Jun 2026 00:49:13 GMT</pubDate>
    <description>最近在看 C++、Rust、Go 的编译过程。

刚开始最容易被一堆名词卡住：

GCC
Clang</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/c-rust-go'>https://liuyaowen.cn/posts/default/c-rust-go</a></blockquote>
          <p>最近在看 C++、Rust、Go 的编译过程。</p>
<p>刚开始最容易被一堆名词卡住：</p>
<ul>
<li>GCC</li>
<li>Clang</li>
<li>LLVM</li>
<li>AST</li>
<li>IR</li>
<li>SSA</li>
<li>MIR</li>
<li>GIMPLE</li>
<li>RTL</li>
<li>Linker</li>
<li>Loader</li>
</ul>
<p>单独看每个词，好像都能理解一点。但把它们放到一张图里，就很容易乱。</p>
<p>后来我发现，问题不在于名词太多，而在于没有从一段真实程序出发。</p>
<p>编译器不是简单地“把源码翻译成汇编”。更准确地说，它是在不断降低程序的抽象层级：</p>
<pre><code class="language-text">人类写的源码
    ↓
结构化表示
    ↓
语义明确的表示
    ↓
适合优化的表示
    ↓
接近机器的表示
    ↓
机器可以执行的二进制</code></pre><p>每一层中间表示，都是为了让某一类问题变得更容易处理。</p>
<p>下面用一段很小的 C++ 程序，把主流编译链路串起来。</p>
<pre><code class="language-cpp">extern "C" int printf(const char*, ...);

#define INC(x) ((x) + 1)

int add(int a, int b) {
    return a + b;
}

int main() {
    int x = INC(add(1, 2));
    printf("%d\n", x);
    return 0;
}</code></pre><p>这段代码故意保留了几个点：</p>
<pre><code class="language-text">宏：INC
函数调用：add
外部符号：printf
C++ 符号：add 会发生 name mangling
链接阶段：printf 需要从 C 标准库解析
优化空间：add(1, 2) 和 INC 可以被优化成常量</code></pre><p>一个小程序，基本够覆盖主流编译流程。</p>
<h2>1. 源码不是编译器真正想要的东西</h2>
<p>程序员看到的是：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>人很容易理解它大概等价于：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>但编译器不能一开始就这么理解。</p>
<p>源码只是字符流：</p>
<pre><code class="language-text">i n t 空格 x 空格 = 空格 I N C ...</code></pre><p>编译器必须先把字符处理成更稳定的结构。</p>
<p>这也是为什么现代编译流程不会从源码直接跳到汇编。源码适合人读，不适合机器分析。</p>
<h2>2. 预处理：C/C++ 最早的一层文本系统</h2>
<p>C/C++ 的第一步通常是预处理。</p>
<pre><code class="language-bash">clang++ -E main.cpp -o main.ii</code></pre><p>预处理器主要处理：</p>
<pre><code class="language-text">#include
#define
#ifdef
#pragma</code></pre><p>在这段程序里，最明显的是：</p>
<pre><code class="language-cpp">#define INC(x) ((x) + 1)</code></pre><p>预处理后，核心代码会变成类似这样：</p>
<pre><code class="language-cpp">int main() {
    int x = ((add(1, 2)) + 1);
    printf("%d\n", x);
    return 0;
}</code></pre><p>这一步有个很重要的认知：</p>
<blockquote>
<p>预处理不是语义分析。</p>
</blockquote>
<p>它不理解类型。</p>
<p>它不知道 <code>add</code> 是不是函数。</p>
<p>它也不知道 <code>INC(add(1, 2))</code> 是否合理。</p>
<p>它只是按照规则展开文本。</p>
<p>这也是 C/C++ 宏容易制造问题的原因。宏不是函数，它不会遵守函数那套类型检查和作用域规则。</p>
<p>例如：</p>
<pre><code class="language-cpp">#define SQUARE(x) x * x</code></pre><p>如果写：</p>
<pre><code class="language-cpp">SQUARE(1 + 2)</code></pre><p>会被展开成：</p>
<pre><code class="language-cpp">1 + 2 * 1 + 2</code></pre><p>而不是：</p>
<pre><code class="language-cpp">(1 + 2) * (1 + 2)</code></pre><p>很多 C/C++ 的奇怪问题，其实还没进入真正编译阶段，只是在预处理阶段就已经埋雷了。</p>
<h2>3. 词法分析：把字符切成 Token</h2>
<p>预处理后，编译器开始做词法分析。</p>
<p>源码片段：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>会被切成类似：</p>
<pre><code class="language-text">keyword      int
identifier   x
operator     =
punctuator   (
punctuator   (
identifier   add
punctuator   (
literal      1
punctuator   ,
literal      2
punctuator   )
punctuator   )
operator     +
literal      1
punctuator   )
punctuator   ;</code></pre><p>这一步只负责“切词”。</p>
<p>它知道 <code>int</code> 是关键字。</p>
<p>知道 <code>x</code> 是标识符。</p>
<p>知道 <code>1</code> 是数字字面量。</p>
<p>但它还不知道：</p>
<pre><code class="language-text">x 是局部变量
add 是函数
printf 是外部函数
1 能不能传给 add</code></pre><p>这些都不是词法分析负责的。</p>
<p>可以用 Clang 看 Token：</p>
<pre><code class="language-bash">clang++ -Xclang -dump-tokens -fsyntax-only main.cpp</code></pre><p>如果编译错误里出现非法字符、字符串没有闭合、数字字面量格式错误，通常就停在这一层附近。</p>
<h2>4. 语法分析：从 Token 变成 AST</h2>
<p>Token 还是线性的。</p>
<p>编译器需要知道程序结构。</p>
<p>例如：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>语法分析后会变成一棵树，简化后大概是：</p>
<pre><code class="language-text">VarDecl x : int
    └── BinaryOperator +
        ├── CallExpr add
        │   ├── IntegerLiteral 1
        │   └── IntegerLiteral 2
        └── IntegerLiteral 1</code></pre><p>这就是 AST，抽象语法树。</p>
<p>AST 的作用不是为了显得高级，而是为了把源码结构稳定下来。</p>
<p>源码是文本。</p>
<p>AST 是结构。</p>
<p>这一步之后，编译器终于知道：</p>
<pre><code class="language-text">这是一个变量声明
变量名是 x
初始化表达式是一个加法
加法左边是函数调用
加法右边是整数 1</code></pre><p>但 AST 仍然不能证明程序正确。</p>
<p>例如：</p>
<pre><code class="language-cpp">int x = foo(1, 2);</code></pre><p>即使 <code>foo</code> 根本不存在，也可以先构建 AST。</p>
<p>语法上没问题。</p>
<p>语义上才有问题。</p>
<p>可以用 Clang 看 AST：</p>
<pre><code class="language-bash">clang++ -Xclang -ast-dump -fsyntax-only main.cpp</code></pre><p>真实 C++ AST 会非常长。模板、重载、隐式转换、构造函数、析构函数都会出现在里面。</p>
<p>这也是 C++ 前端复杂度很高的原因。不是因为 Token 难切，也不是因为语法树难画，而是因为语义太复杂。</p>
<h2>5. 语义分析：编译器真正开始理解程序</h2>
<p>语义分析会处理这些问题：</p>
<pre><code class="language-text">add 是否存在
add 参数数量是否正确
add 参数类型是否匹配
printf 是否声明过
int x 的初始化是否合法
函数返回值是否符合声明</code></pre><p>对这段代码来说：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>预处理后是：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>语义分析会确认：</p>
<pre><code class="language-text">add 的类型是 int(int, int)
add(1, 2) 返回 int
返回值可以和 1 做加法
结果可以赋给 int x</code></pre><p>如果写成：</p>
<pre><code class="language-cpp">int x = add("hello", 2);</code></pre><p>语法仍然成立。</p>
<p>AST 也能构建。</p>
<p>但语义分析会报错，因为参数类型不匹配。</p>
<p>很多开发者平时说“编译错误”，其实可以再细分：</p>
<pre><code class="language-text">语法错误：代码结构不合法
语义错误：结构合法，但意思不合法
链接错误：单个文件能编译，但最终符号找不到</code></pre><p>这三个错误发生在完全不同的阶段。</p>
<p>Rust 的 Borrow Checker 也属于这个大范围。</p>
<p>例如：</p>
<pre><code class="language-rust">let r;

{
    let x = 1;
    r = &x;
}

println!("{}", r);</code></pre><p>这不是语法错误。</p>
<p>它的结构完全合法。</p>
<p>真正的问题是语义层面的：<code>x</code> 已经离开作用域，<code>r</code> 不能再引用它。</p>
<p>所以 Rust 的所有权、借用、生命周期，不应该理解成语法特性，而应该理解成更强的语义分析。</p>
<p>这也是 Rust 编译器比 Go 编译器复杂很多的原因之一。</p>
<h2>6. AST 不适合优化，所以需要 IR</h2>
<p>很多人第一次学编译流程，会以为：</p>
<pre><code class="language-text">源码
  ↓
AST
  ↓
汇编</code></pre><p>这个模型太粗糙。</p>
<p>现代编译器不会长期停留在 AST 上做优化。</p>
<p>原因很简单：</p>
<blockquote>
<p>AST 太接近源码。</p>
</blockquote>
<p>例如：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>AST 会保留很多源码结构。</p>
<p>但优化器真正关心的是：</p>
<pre><code class="language-text">这个值从哪里来
这个值被谁使用
这个计算有没有副作用
这个分支是否可达
这个变量能不能放进寄存器
这个函数能不能内联</code></pre><p>AST 对这些问题并不友好。</p>
<p>所以编译器会把 AST 降低到 IR。</p>
<p>IR 是 Intermediate Representation，中间表示。</p>
<p>比如这段：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>可以理解为降低成类似三地址码：</p>
<pre><code class="language-text">t1 = call add, 1, 2
t2 = t1 + 1
x = t2
call printf, "%d\n", x
return 0</code></pre><p>这就比 AST 更适合优化。</p>
<p>表达式树被拆成了简单指令。</p>
<p>数据依赖也更清楚。</p>
<p>LLVM IR 会更像这样，简化后：</p>
<pre><code class="language-llvm">define i32 @_Z3addii(i32 %a, i32 %b) {
entry:
  %sum = add nsw i32 %a, %b
  ret i32 %sum
}

define i32 @main() {
entry:
  %call = call i32 @_Z3addii(i32 1, i32 2)
  %add = add nsw i32 %call, 1
  call i32 (ptr, ...) @printf(ptr @.str, i32 %add)
  ret i32 0
}</code></pre><p>这里有几个点值得注意。</p>
<p><code>add</code> 在 C++ 里可能会被编译成：</p>
<pre><code class="language-text">_Z3addii</code></pre><p>这是 C++ name mangling。</p>
<p>因为 C++ 支持函数重载，链接器不能只看到一个名字 <code>add</code>。</p>
<p>例如：</p>
<pre><code class="language-cpp">int add(int, int);
double add(double, double);</code></pre><p>这两个函数源码里都叫 <code>add</code>，但链接时必须区分。</p>
<p>而 <code>printf</code> 因为声明成：</p>
<pre><code class="language-cpp">extern "C" int printf(const char*, ...);</code></pre><p>所以不会被 C++ 改名，仍然叫：</p>
<pre><code class="language-text">printf</code></pre><p>这就是 <code>extern &quot;C&quot;</code> 的实际意义之一：控制符号名，方便和 C ABI 对接。</p>
<h2>7. 优化：编译器开始改写程序</h2>
<p>如果不开优化，编译器会比较忠实地保留程序结构。</p>
<p>如果打开优化：</p>
<pre><code class="language-bash">clang++ -O2 -S -emit-llvm main.cpp -o main.ll</code></pre><p>这段代码很可能被优化成类似：</p>
<pre><code class="language-llvm">define i32 @main() {
entry:
  call i32 (ptr, ...) @printf(ptr @.str, i32 4)
  ret i32 0
}</code></pre><p><code>add(1, 2)</code> 没了。</p>
<p><code>INC</code> 展开后的 <code>+ 1</code> 也没了。</p>
<p><code>x</code> 也没了。</p>
<p>最后只剩：</p>
<pre><code class="language-text">printf("%d\n", 4)</code></pre><p>这不是编译器乱改代码。</p>
<p>这是它证明了这些改写不会改变可观察行为。</p>
<p>这里发生了几类典型优化：</p>
<pre><code class="language-text">函数内联：add(1, 2) 被展开
常量折叠：1 + 2 + 1 变成 4
死代码删除：中间变量 x 不再需要</code></pre><p>如果看过 Java JIT、Go SSA、Rust LLVM 优化，会发现这些优化名字经常重复出现。</p>
<p>原因很简单：程序优化的基本问题是相通的。</p>
<h2>8. SSA：现代优化器为什么喜欢“变量只赋值一次”</h2>
<p>SSA 是理解现代编译器优化的关键。</p>
<p>SSA，全称 Static Single Assignment。</p>
<p>意思是每个变量在静态程序里只赋值一次。</p>
<p>看这个例子：</p>
<pre><code class="language-cpp">int f(bool cond) {
    int x = 1;

    if (cond) {
        x = 2;
    }

    return x;
}</code></pre><p>普通代码里，<code>x</code> 被赋值两次。</p>
<p>控制流一复杂，优化器就很难判断某个位置的 <code>x</code> 到底来自哪里。</p>
<p>SSA 会把它变成类似：</p>
<pre><code class="language-text">x1 = 1

if cond goto then else merge

then:
  x2 = 2
  goto merge

merge:
  x3 = phi(x1, x2)
  return x3</code></pre><p>这里的：</p>
<pre><code class="language-text">phi(x1, x2)</code></pre><p>表示：</p>
<pre><code class="language-text">如果从未进入 if 的路径来，x3 = x1
如果从 then 分支来，x3 = x2</code></pre><p>SSA 的价值在于，它把“变量会变化”这件事变成了显式的数据流关系。</p>
<p>这对优化非常重要。</p>
<p>例如：</p>
<pre><code class="language-text">常量传播
死代码删除
公共子表达式消除
循环不变代码外提
值编号
逃逸分析</code></pre><p>都会受益于 SSA。</p>
<p>LLVM IR 是 SSA 形式。</p>
<p>Go 编译器内部使用 SSA。</p>
<p>HotSpot C2 也使用接近 SSA 的 Sea of Nodes IR。</p>
<p>很多数据库优化器虽然不叫 SSA，但也会维护类似的数据依赖关系。</p>
<p>这不是某个编译器的小技巧，而是现代优化系统的基础方法。</p>
<h2>9. 后端：从平台无关 IR 到机器相关 IR</h2>
<p>LLVM IR 仍然是平台无关的。</p>
<p>比如：</p>
<pre><code class="language-llvm">%sum = add i32 %a, %b</code></pre><p>这句话没有指定：</p>
<pre><code class="language-text">x86 用哪条指令
ARM 用哪条指令
结果放哪个寄存器
参数从哪里来
调用约定是什么</code></pre><p>这些都是后端的问题。</p>
<p>后端要处理：</p>
<pre><code class="language-text">指令选择
寄存器分配
指令调度
调用约定
栈帧布局
目标平台 ABI</code></pre><p>同一个加法，在 x86-64 下可能是：</p>
<pre><code class="language-asm">mov eax, edi
add eax, esi
ret</code></pre><p>在 ARM64 下可能是：</p>
<pre><code class="language-asm">add w0, w0, w1
ret</code></pre><p>IR 相同，目标机器不同，最终指令就不同。</p>
<p>这也是 LLVM 的价值所在。</p>
<p>语言实现者只要生成 LLVM IR，就可以复用 LLVM 的后端能力。</p>
<p>否则每写一门语言，都要自己支持：</p>
<pre><code class="language-text">x86-64
ARM64
RISC-V
Windows ABI
Linux ABI
macOS ABI
寄存器分配
指令选择
调试信息
异常处理</code></pre><p>这几乎不现实</p>
<h2>10. 目标文件：机器码还不是最终程序</h2>
<p>后端生成汇编后，汇编器会生成目标文件：</p>
<pre><code class="language-bash">clang++ -c main.cpp -o main.o</code></pre><p><code>main.o</code> 里面有机器码，但它还不是完整程序。</p>
<p>可以用：</p>
<pre><code class="language-bash">nm main.o</code></pre><p>看到符号。</p>
<p>可能会看到类似：</p>
<pre><code class="language-text">0000000000000000 T _Z3addii
0000000000000010 T main
                 U printf</code></pre><p>含义大概是：</p>
<pre><code class="language-text">_Z3addii 已定义
main 已定义
printf 未定义</code></pre><p><code>U printf</code> 不是错误。</p>
<p>它只是说：当前目标文件里没有 <code>printf</code> 的实现，链接阶段需要去别的地方找。</p>
<p>这就是很多人第一次遇到 <code>undefined reference</code> 时容易误解的地方。</p>
<p>它不是语法错误。</p>
<p>也不是类型错误。</p>
<p>它是链接阶段的符号解析失败。</p>
<h2>11. 链接：把分散的二进制拼成一个程序</h2>
<p>链接器做的事情可以粗略理解为：</p>
<pre><code class="language-text">main.o
  +
libc
  +
启动代码
  +
运行时库
  ↓
可执行文件</code></pre><p>它要解决：</p>
<pre><code class="language-text">符号解析
地址分配
重定位
静态库选择
动态库记录
入口点设置</code></pre><p>如果写 C++，还会涉及：</p>
<pre><code class="language-text">name mangling
ODR
模板实例化
静态初始化
异常表
RTTI
虚表</code></pre><p>比如：</p>
<pre><code class="language-text">undefined reference to `foo'</code></pre><p>说明链接器找不到 <code>foo</code> 的实现。</p>
<pre><code class="language-text">multiple definition of `foo'</code></pre><p>说明多个目标文件都提供了同一个强符号。</p>
<pre><code class="language-text">undefined reference to `_Z3addii'</code></pre><p>说明 C++ 符号名对不上，可能是声明和定义不一致，也可能是 C/C++ ABI 混用出了问题。</p>
<p>链接器是独立于编译器前端的另一个大系统。</p>
<p>很多大型 C++ 工程的构建问题，其实不是编译器问题，而是链接器问题。</p>
<h2>12. 加载：程序运行前，操作系统还要接手</h2>
<p>执行：</p>
<pre><code class="language-bash">./a.out</code></pre><p>也不是 CPU 直接从 <code>main</code> 开始跑。</p>
<p>Linux 下大致会经历：</p>
<pre><code class="language-text">内核读取 ELF
映射程序段到虚拟内存
加载动态链接器
加载共享库
处理重定位
初始化运行时
调用 main</code></pre><p>程序的内存布局大致包括：</p>
<pre><code class="language-text">.text     代码段
.rodata   只读数据
.data     已初始化全局变量
.bss      未初始化全局变量
heap      堆
stack     栈</code></pre><p>所以严格说，完整链路不是：</p>
<pre><code class="language-text">源码 → 可执行文件</code></pre><p>而是：</p>
<pre><code class="language-text">源码 → 目标文件 → 可执行文件 → 进程</code></pre><p>编译器解决“怎么生成程序”。</p>
<p>链接器解决“怎么合成程序”。</p>
<p>加载器解决“怎么把程序变成进程”。</p>
<h2>13. Clang、LLVM、GCC 应该怎么放在一张图里</h2>
<p>现在再看这些名字，就清楚很多。</p>
<p>Clang + LLVM 是这样：</p>
<pre><code class="language-text">C++ Source
    ↓
Clang Frontend
    ↓
Clang AST
    ↓
LLVM IR
    ↓
LLVM Optimizer
    ↓
LLVM Backend
    ↓
Object File
    ↓
Linker
    ↓
Executable</code></pre><p>GCC 是另一条链路：</p>
<pre><code class="language-text">C++ Source
    ↓
GCC C++ Frontend
    ↓
GENERIC
    ↓
GIMPLE
    ↓
GIMPLE SSA
    ↓
RTL
    ↓
GCC Backend
    ↓
Object File
    ↓
Linker
    ↓
Executable</code></pre><p>这两条链路解决的是同一类问题，但内部表示不同。</p>
<p>Clang 不是 LLVM 的别名。</p>
<p>LLVM 也不是 Clang 的别名。</p>
<p>更准确地说：</p>
<pre><code class="language-text">Clang 是 C/C++/Objective-C 前端。
LLVM 是中端和后端基础设施。
GCC 是另一套完整编译器系统。</code></pre><p>对应关系可以这样记：</p>
<pre><code class="language-text">Clang 负责读懂 C++。
LLVM 负责优化和生成机器码。
GCC 自己既有前端，也有中端和后端。</code></pre><h2>14. Rust 为什么有 HIR、MIR，又为什么用 LLVM</h2>
<p>Rust 的流程大致是：</p>
<pre><code class="language-text">Rust Source
    ↓
AST
    ↓
HIR
    ↓
THIR
    ↓
MIR
    ↓
Borrow Check
    ↓
LLVM IR
    ↓
LLVM Backend
    ↓
Machine Code</code></pre><p>Rust 这几层不是为了显得复杂。</p>
<p>每层都有明确责任。</p>
<p>AST 接近源码。</p>
<p>HIR 会把一些语法糖和表层结构降下来，让程序形态更稳定。</p>
<p>THIR 更适合类型检查后的表达式分析。</p>
<p>MIR 是 Rust 很关键的一层，适合表达控制流、move、borrow、drop、生命周期等语义。</p>
<p>LLVM IR 则负责进入通用优化和机器码生成阶段。</p>
<p>这里要注意一个区分：</p>
<pre><code class="language-text">MIR 是 Rust 语义层的 IR。
LLVM IR 是机器代码生成层的 IR。</code></pre><p>Borrow Checker 不能直接依赖 LLVM IR。</p>
<p>因为 LLVM IR 已经太低级，Rust 的 ownership、borrow、drop 语义在那一层已经不适合作为主要分析对象。</p>
<p>也不能直接依赖 AST。</p>
<p>因为 AST 太接近语法表面，语法糖太多，不适合做严格的数据流和控制流分析。</p>
<p>所以 Rust 需要 MIR。</p>
<p>这就是中间表示真正的价值：不是多一层抽象，而是为特定分析提供合适的程序形态。</p>
<h2>15. Go 为什么看起来简单很多</h2>
<p>Go 的编译流程大致是：</p>
<pre><code class="language-text">Go Source
    ↓
Parser
    ↓
AST
    ↓
Type Check
    ↓
SSA
    ↓
Go Backend
    ↓
Machine Code</code></pre><p>Go 默认不走 LLVM。</p>
<p>它有自己的 SSA 和后端。</p>
<p>这和 Go 的设计目标有关。</p>
<p>Go 语言本身刻意保持简单：</p>
<pre><code class="language-text">没有模板元编程
没有复杂宏系统
没有 Rust 那种 ownership 检查
没有 C++ 那种重载和隐式规则</code></pre><p>所以 Go 编译器可以更直接。</p>
<p>这也是 Go 编译速度快的一个重要原因。</p>
<p>当然，“简单”不是说 Go 编译器没有技术含量。</p>
<p>Go 的逃逸分析、内联、SSA 优化、栈增长、GC 相关元数据生成，都有不少工程细节。</p>
<p>但和 C++、Rust 相比，Go 前端语义复杂度确实低很多。</p>
<h2>16. 为什么现代编译器总在发明新的 IR</h2>
<p>到这里，基本可以回答最初的问题。</p>
<p>为什么 GCC 有 GIMPLE 和 RTL？</p>
<p>为什么 LLVM 有 LLVM IR、SelectionDAG、Machine IR？</p>
<p>为什么 Rust 有 HIR、THIR、MIR？</p>
<p>为什么 Go 有 SSA？</p>
<p>因为没有一种表示适合所有阶段。</p>
<p>AST 适合表示源码结构。</p>
<p>HIR 适合消除表层语法。</p>
<p>MIR 适合表达语言语义和控制流。</p>
<p>LLVM IR 适合做平台无关优化。</p>
<p>Machine IR 适合做寄存器分配和指令调度。</p>
<p>RTL 适合 GCC 后端描述接近机器的操作。</p>
<p>同一段程序，在不同阶段要被看成不同的东西。</p>
<p>对人来说，它是业务逻辑。</p>
<p>对前端来说，它是语法树。</p>
<p>对类型系统来说，它是约束集合。</p>
<p>对优化器来说，它是数据流图。</p>
<p>对后端来说，它是指令选择和寄存器分配问题。</p>
<p>对链接器来说，它是符号和重定位记录。</p>
<p>对加载器来说，它是 ELF 段和动态库依赖。</p>
<p>编译器的复杂性，正是来自这些视角之间的切换。</p>
<h2>17. 真正需要记住的主流流程</h2>
<p>如果只保留主流路径，不陷入全部细节，可以记成这条线：</p>
<pre><code class="language-text">源码
  ↓
预处理
  ↓
Token
  ↓
AST
  ↓
语义分析
  ↓
IR
  ↓
SSA
  ↓
优化
  ↓
目标相关 IR
  ↓
汇编 / 目标文件
  ↓
链接
  ↓
可执行文件
  ↓
加载执行</code></pre><p>对应到几个主流编译器：</p>
<pre><code class="language-text">Clang/LLVM:
C++ → Clang AST → LLVM IR → LLVM Backend → Object

GCC:
C++ → GENERIC → GIMPLE SSA → RTL → Object

Rust:
Rust → AST/HIR/THIR → MIR → LLVM IR → Object

Go:
Go → AST → Type Check → SSA → Go Backend → Object</code></pre><p>这张图比单独背 AST、IR、SSA 更有用。</p>
<p>因为它告诉你每个系统在同一条工业流水线里的位置。</p>
<h2>18. 学这套东西对工程有什么用</h2>
<p>这不是纯理论。</p>
<p>遇到宏问题，你知道去看预处理结果：</p>
<pre><code class="language-bash">clang++ -E main.cpp</code></pre><p>遇到语法和语义问题，你知道它发生在前端：</p>
<pre><code class="language-bash">clang++ -Xclang -ast-dump -fsyntax-only main.cpp</code></pre><p>想看优化前后的差异，你知道去看 LLVM IR：</p>
<pre><code class="language-bash">clang++ -O0 -S -emit-llvm main.cpp -o main_O0.ll
clang++ -O2 -S -emit-llvm main.cpp -o main_O2.ll</code></pre><p>遇到符号找不到，你知道看目标文件：</p>
<pre><code class="language-bash">nm main.o</code></pre><p>想看 ELF 结构：</p>
<pre><code class="language-bash">readelf -a a.out</code></pre><p>想看汇编：</p>
<pre><code class="language-bash">objdump -d a.out</code></pre><p>这些工具不是为了炫技。</p>
<p>它们对应的是编译流程中的不同阶段。</p>
<p>能定位阶段，问题就已经解决了一半。</p>
<h2>19. 编译器真正有价值的地方</h2>
<p>编译器最有价值的地方，不是几个术语，而是一种看复杂系统的方法。</p>
<p>一个复杂输入，不会被直接执行。</p>
<p>它会先变成某种中间表示。</p>
<p>然后被分析、约束、优化、降低，最后才进入执行层。</p>
<p>这个模式不只存在于编译器里。</p>
<p>数据库会把 SQL 变成逻辑计划和物理计划。</p>
<p>JVM 会把字节码变成内部 IR，再交给 JIT 优化。</p>
<p>React 会把 UI 更新组织成 Fiber 结构。</p>
<p>Kubernetes Scheduler 会把调度请求变成资源约束和评分模型。</p>
<p>这些系统看起来不一样，但底层思路很接近：</p>
<pre><code class="language-text">输入不是直接执行的。
先建立表示。
再基于表示做分析和优化。
最后执行。</code></pre><p>编译器只是这个思想最经典、最完整的版本。</p>
<p>如果能把 C++、Rust、Go、GCC、LLVM 这条线看懂，再回头看 JVM、数据库、前端框架、调度系统，很多设计就不再是孤立的名词了。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/c-rust-go#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233521</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>一次诡异的 Spring 循环依赖问题：三级缓存拿到的是原始对象，最终却变成了代理对象</title>
    <link>https://liuyaowen.cn/posts/default/spring</link>
    <pubDate>Fri, 05 Jun 2026 11:47:08 GMT</pubDate>
    <description>最近排查了一个 Spring 启动问题，异常并不陌生：

Bean with name &apos;xxx&apos; </description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/spring'>https://liuyaowen.cn/posts/default/spring</a></blockquote>
          <hr>
<p>最近排查了一个 Spring 启动问题，异常并不陌生：</p>
<pre><code class="language-text">Bean with name 'xxx' has been injected into other beans
in its raw version as part of a circular reference,
but has eventually been wrapped.</code></pre><p>第一眼看到的时候，我的反应是：</p>
<ul>
<li>三级缓存失效了？</li>
<li>AOP 出问题了？</li>
<li>Spring Bug？</li>
<li>代理创建顺序异常？</li>
</ul>
<p>因为按我的理解，既然已经通过三级缓存解决了循环依赖，那么最终拿到的对象和提前暴露出去的对象应该是同一个。</p>
<p>结果一路跟源码，最后发现根本不是这么回事</p>
<h1>问题现场</h1>
<p>依赖关系非常简单：</p>
<pre><code class="language-text">A
↓
B
↓
Repository
↓
A</code></pre><p>形成循环依赖。</p>
<p>Spring 启动过程中成功进入三级缓存。</p>
<p>调试发现：</p>
<p>Early Reference：</p>
<pre><code class="language-java">UserRepository</code></pre><p>最终 Bean：</p>
<pre><code class="language-java">UserRepository$$SpringCGLIB$$0</code></pre><p>明显已经被代理。</p>
<p>也就是说：</p>
<pre><code class="language-text">Early Bean != Final Bean</code></pre><p>这正是异常的来源。</p>
<h1>第一怀疑对象：事务代理</h1>
<p>看到 CGLIB 的第一反应就是：</p>
<pre><code class="language-java">@Transactional</code></pre><p>于是一路跟到：</p>
<pre><code class="language-java">AbstractAutoProxyCreator</code></pre><p>查看：</p>
<pre><code class="language-java">getEarlyBeanReference()</code></pre><p>和：</p>
<pre><code class="language-java">postProcessAfterInitialization()</code></pre><p>结果发现一个奇怪的现象。</p>
<p>整个生命周期中：</p>
<pre><code class="language-java">findEligibleAdvisors()</code></pre><p>只执行了一次。</p>
<p>而且只发生在：</p>
<pre><code class="language-java">getEarlyBeanReference()</code></pre><p>阶段。</p>
<p>说明：</p>
<pre><code class="language-text">事务代理并不是最终代理来源</code></pre><p>第一条线索断掉。</p>
<h1>如何确定是谁创建了最终代理</h1>
<p>查看最终代理对象：</p>
<pre><code class="language-java">Object bean = applicationContext.getBean("userRepository");

Advised advised = (Advised) bean;

for (Advisor advisor : advised.getAdvisors()) {
    System.out.println(advisor);
}</code></pre><p>结果发现：</p>
<pre><code class="language-text">PersistenceExceptionTranslationAdvisor</code></pre><p>真凶出现了。</p>
<h1>一个经常被忽略的代理</h1>
<p>这个 Advisor 来自：</p>
<pre><code class="language-java">PersistenceExceptionTranslationPostProcessor</code></pre><p>很多人可能没注意过它。</p>
<p>当 Spring 发现：</p>
<pre><code class="language-java">@Repository</code></pre><p>时，会自动创建异常翻译代理。</p>
<p>例如：</p>
<pre><code class="language-java">@Repository
public class UserRepository {
}</code></pre><p>原始异常：</p>
<pre><code class="language-java">SQLException
PersistenceException
HibernateException</code></pre><p>会被转换成：</p>
<pre><code class="language-java">DataAccessException
DuplicateKeyException</code></pre><p>统一 Spring 数据访问异常体系。</p>
<h1>真正的问题</h1>
<p>继续跟源码。</p>
<p>事务代理对应的：</p>
<pre><code class="language-java">AbstractAutoProxyCreator</code></pre><p>实现了：</p>
<pre><code class="language-java">SmartInstantiationAwareBeanPostProcessor</code></pre><p>因此支持：</p>
<pre><code class="language-java">getEarlyBeanReference()</code></pre><p>所以事务代理可以做到：</p>
<pre><code class="language-text">Early Bean = Proxy
Final Bean = Proxy</code></pre><p>但是：</p>
<pre><code class="language-java">PersistenceExceptionTranslationPostProcessor</code></pre><p>只是普通：</p>
<pre><code class="language-java">BeanPostProcessor</code></pre><p>它根本不会参与：</p>
<pre><code class="language-java">getEarlyBeanReference()</code></pre><p>只会在：</p>
<pre><code class="language-java">postProcessAfterInitialization()</code></pre><p>阶段执行。</p>
<p>于是整个流程变成：</p>
<pre><code class="language-text">Bean 创建
↓
加入三级缓存
↓
发生循环依赖
↓
获取 Early Bean
↓
拿到原始对象
↓
初始化完成
↓
PersistenceExceptionTranslationPostProcessor
创建代理
↓
Final Bean 变成代理对象</code></pre><p>最终：</p>
<pre><code class="language-text">Early Bean != Final Bean</code></pre><p>Spring 检测到对象身份发生变化，直接抛异常。</p>
<h1>为什么事务代理可以，异常翻译代理却不行？</h1>
<p>跟到这里的时候，我也产生过一个疑问。</p>
<p>既然 Spring 已经有 Early Proxy 机制：</p>
<pre><code class="language-java">getEarlyBeanReference()</code></pre><p>为什么异常翻译代理不走这一套？</p>
<p>继续往下看 Spring Framework 的实现后发现：</p>
<p>这其实是一个设计取舍。</p>
<p>Spring 只会为：</p>
<pre><code class="language-text">影响 Bean 核心行为</code></pre><p>的代理提供 Early Proxy。</p>
<p>例如：</p>
<pre><code class="language-java">@Transactional
@Aspect
@Cacheable</code></pre><p>这些代理如果失效，业务逻辑会直接出错。</p>
<p>因此必须保证：</p>
<pre><code class="language-text">Early = Final</code></pre><p>而：</p>
<pre><code class="language-java">@Repository</code></pre><p>异常翻译只是附加能力。</p>
<p>没有它：</p>
<pre><code class="language-java">SQLException</code></pre><p>一样可以正常抛出。</p>
<p>因此 Spring 认为没有必要为了这种场景增加整个容器复杂度。</p>
<h1>Spring 是如何避免重复代理的</h1>
<p>在：</p>
<pre><code class="language-java">AbstractAutoProxyCreator</code></pre><p>中有这样一段逻辑：</p>
<pre><code class="language-java">public Object getEarlyBeanReference(
        Object bean,
        String beanName) {

    Object cacheKey = getCacheKey(bean.getClass(), beanName);

    this.earlyProxyReferences.put(cacheKey, bean);

    return wrapIfNecessary(bean, beanName, cacheKey);
}</code></pre><p>随后：</p>
<pre><code class="language-java">public Object postProcessAfterInitialization(
        Object bean,
        String beanName) {

    Object cacheKey = getCacheKey(bean.getClass(), beanName);

    if (this.earlyProxyReferences.remove(cacheKey) != bean) {
        return wrapIfNecessary(bean, beanName, cacheKey);
    }

    return bean;
}</code></pre><p>如果已经在 Early 阶段创建过代理：</p>
<pre><code class="language-text">Early Proxy</code></pre><p>那么 Final 阶段就不会再次代理。</p>
<p>保证：</p>
<pre><code class="language-text">Early Bean == Final Bean</code></pre><p>但是：</p>
<pre><code class="language-java">PersistenceExceptionTranslationPostProcessor</code></pre><p>根本不参与这套机制。</p>
<p>因此无法保证一致性。</p>
<h1>根因</h1>
<p>最终定位发现：</p>
<pre><code class="language-java">@Repository</code></pre><p>标注的 Bean 参与了循环依赖。</p>
<p>循环依赖期间：</p>
<pre><code class="language-text">三级缓存暴露的是原始对象</code></pre><p>初始化完成后：</p>
<pre><code class="language-text">PersistenceExceptionTranslationPostProcessor
再次包装代理</code></pre><p>导致：</p>
<pre><code class="language-text">Raw Bean 被注入
Final Bean 变成 Proxy</code></pre><p>最终触发 Spring 的一致性检查。</p>
<h1>解决方案</h1>
<p>优先级从高到低：</p>
<h2>方案一：拆除循环依赖（推荐）</h2>
<pre><code class="language-text">Service
↓
Repository</code></pre><p>不要形成：</p>
<pre><code class="language-text">Service
↓
Repository
↓
Service</code></pre><p>这是根治方案。</p>
<h2>方案二：非 DAO 不要滥用 @Repository</h2>
<p>很多项目喜欢：</p>
<pre><code class="language-java">@Repository
public class XxxManager {
}</code></pre><p>实际上并不访问数据库。</p>
<p>这种应该改成：</p>
<pre><code class="language-java">@Component</code></pre><p>或者：</p>
<pre><code class="language-java">@Service</code></pre><p>即可。</p>
<h2>方案三：使用 @Lazy</h2>
<pre><code class="language-java">@Autowired
@Lazy
private UserRepository repository;</code></pre><p>避免提前创建</p>
<h2>方案四：关闭异常翻译（一般不推荐）</h2>
<p>移除：</p>
<pre><code class="language-java">PersistenceExceptionTranslationPostProcessor</code></pre><p>或者对应自动配置。</p>
<h1>一个容易忽略的知识点</h1>
<p>很多人以为：</p>
<pre><code class="language-text">Spring 三级缓存解决了循环依赖
=
所有代理都能正常工作</code></pre><p>实际上并不是。</p>
<p>更准确的说法应该是：</p>
<pre><code class="language-text">Spring 三级缓存
只能保证支持 Early Proxy 的代理机制正常工作</code></pre><p>对于：</p>
<pre><code class="language-java">PersistenceExceptionTranslationPostProcessor
MethodValidationPostProcessor
部分 Async/Scheduled 后处理器
自定义 BeanPostProcessor</code></pre><p>如果它们只在：</p>
<pre><code class="language-java">postProcessAfterInitialization()</code></pre><p>阶段创建代理，</p>
<p>那么循环依赖场景下仍然可能出现：</p>
<pre><code class="language-text">Early Bean ≠ Final Bean</code></pre><p>从而触发 Spring 的一致性校验异常。</p>
<h1>总结</h1>
<p>这次问题最大的收获其实不是解决了循环依赖。</p>
<p>而是搞清楚了一个以前一直模糊的认知：</p>
<pre><code class="language-text">Spring 三级缓存
≠
解决所有循环依赖问题</code></pre><p>真正准确的说法应该是：</p>
<pre><code class="language-text">Spring 三级缓存
只能解决支持 Early Proxy 的循环依赖问题</code></pre><p>对于那些只在 Bean 初始化完成后才创建代理的 BeanPostProcessor：</p>
<pre><code class="language-java">postProcessAfterInitialization()</code></pre><p>仍然可能出现：</p>
<pre><code class="language-text">Early Bean ≠ Final Bean</code></pre><p>而这，正是那个异常背后的真正原因。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/spring#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233520</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title># 从 Linux 网络到 Kubernetes 网络：彻底理解 Pod 是如何通信的</title>
    <link>https://liuyaowen.cn/posts/default/linux-kubernetes-pod</link>
    <pubDate>Tue, 02 Jun 2026 14:01:18 GMT</pubDate>
    <description>很多人学习 Kubernetes 网络时，一上来就接触：

Pod
Service
Ingress
</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/linux-kubernetes-pod'>https://liuyaowen.cn/posts/default/linux-kubernetes-pod</a></blockquote>
          <p>很多人学习 Kubernetes 网络时，一上来就接触：</p>
<ul>
<li>Pod</li>
<li>Service</li>
<li>Ingress</li>
<li>CNI</li>
<li>Flannel</li>
<li>Calico</li>
</ul>
<p>结果越学越乱。</p>
<p>真正的问题在于：</p>
<blockquote>
<p>Kubernetes 网络并不是一个独立体系，而是建立在 Linux 网络之上的一层抽象。</p>
</blockquote>
<p>如果不了解 Linux 网络，那么 Pod、Service、VXLAN 这些概念都会变成黑盒。</p>
<h2>一、网络世界只有三个核心概念</h2>
<p>整个网络可以抽象成三件事：</p>
<pre><code class="language-text">设备
地址
转发</code></pre><p>对应：</p>
<pre><code class="language-text">设备：
网卡
交换机
路由器

地址：
MAC
IP

转发：
交换
路由
NAT</code></pre><h2>二、MAC 与 IP</h2>
<h3>MAC 地址</h3>
<p>MAC 是二层地址。</p>
<pre><code class="language-text">00:11:22:33:44:55</code></pre><p>作用是在同一个局域网内定位设备。</p>
<h3>IP 地址</h3>
<p>IP 是三层地址。</p>
<pre><code class="language-text">192.168.1.10
10.244.1.2
8.8.8.8</code></pre><p>作用是跨网络定位设备。</p>
<h2>三、交换机、ARP、路由器</h2>
<p>交换机工作在二层，只看 MAC 地址，不看 IP、TCP、HTTP。</p>
<p>ARP 负责：</p>
<pre><code class="language-text">IP -&gt; MAC</code></pre><p>路由器工作在三层：</p>
<pre><code class="language-text">查看目标 IP
↓
查路由表
↓
决定下一跳</code></pre><p>路由表示例：</p>
<pre><code class="language-bash">ip route</code></pre><pre><code class="language-text">192.168.1.0/24 dev eth0
default via 192.168.1.1</code></pre><p>含义：</p>
<pre><code class="language-text">同网段直接发送
其他流量交给默认网关</code></pre><h2>四、NAT</h2>
<p>私网地址不能直接在公网路由：</p>
<pre><code class="language-text">192.168.x.x
172.16.x.x
10.x.x.x</code></pre><p>所以路由器会做 SNAT：</p>
<pre><code class="language-text">SRC 192.168.1.10
↓
SRC 公网 IP</code></pre><p>并通过 <code>conntrack</code> 记录连接映射，用于回包恢复。</p>
<h2>五、Linux Network Namespace</h2>
<p>Namespace 是 Linux 的隔离机制。</p>
<p>Network Namespace 隔离的是一整套网络栈：</p>
<pre><code class="language-text">网卡
IP
路由表
ARP 表
iptables
端口空间</code></pre><p>所以容器不是虚拟机，而是：</p>
<pre><code class="language-text">普通 Linux 进程
+
Namespace 隔离
+
Cgroups 限制
+
RootFS 文件系统</code></pre><h2>六、veth Pair</h2>
<p>Namespace 之间需要连接，Linux 提供了 <code>veth pair</code>。</p>
<pre><code class="language-text">vethA &lt;====&gt; vethB</code></pre><p>它像一根虚拟网线：</p>
<pre><code class="language-text">从 vethA 发出的包，会从 vethB 出来
从 vethB 发出的包，会从 vethA 出来</code></pre><h2>七、Linux Bridge</h2>
<p>Bridge 是软件交换机。</p>
<pre><code class="language-text">ContainerA
   |
 Bridge
   |
ContainerB</code></pre><p>它和物理交换机一样：</p>
<pre><code class="language-text">学习 MAC
查 MAC 表
转发数据帧</code></pre><h2>八、Docker 网络</h2>
<p>Docker 默认网络结构：</p>
<pre><code class="language-text">Container
  eth0
    │
veth
    │
docker0
    │
Host</code></pre><p>其中 <code>docker0</code> 是 Linux Bridge，同时被 Docker 配置了 IP：</p>
<pre><code class="language-text">docker0 = 172.17.0.1</code></pre><p>所以 <code>docker0</code> 既是交换机，也是容器默认网关。</p>
<p>Docker 自己维护 IPAM：</p>
<pre><code class="language-text">172.17.0.0/16</code></pre><p>容器 IP 通常由 Docker 分配：</p>
<pre><code class="language-text">172.17.0.2
172.17.0.3
172.17.0.4</code></pre><h2>九、Pod 是什么</h2>
<p>Pod 不是容器。</p>
<p>一个 Pod 里可以有多个容器：</p>
<pre><code class="language-text">Pod
├── app
└── sidecar</code></pre><p>真正持有网络命名空间的是 <code>pause container</code>。</p>
<pre><code class="language-text">pause container
└── Network Namespace

app
└── 加入 pause 的 Network Namespace

sidecar
└── 加入 pause 的 Network Namespace</code></pre><p>所以同一个 Pod 内多个容器共享：</p>
<pre><code class="language-text">IP
localhost
端口空间</code></pre><h2>十、Kubernetes 单节点网络</h2>
<p>很多 CNI 会在 Node 上创建类似 <code>docker0</code> 的网桥，比如：</p>
<pre><code class="language-text">cni0</code></pre><p>结构：</p>
<pre><code class="language-text">PodA
  |
veth
  |
cni0
  |
veth
  |
PodB</code></pre><p>同节点 Pod 通信时：</p>
<pre><code class="language-text">PodA ARP 查询 PodB MAC
↓
cni0 广播
↓
PodB 回复
↓
cni0 根据 MAC 表转发</code></pre><p>这个过程本质是二层交换。</p>
<h2>十一、CNI 是什么</h2>
<p>Kubernetes 本身不直接实现网络。</p>
<p>它通过 CNI 插件处理 Pod 网络。</p>
<p>常见 CNI：</p>
<pre><code class="language-text">Flannel
Calico
Cilium</code></pre><p>CNI 负责：</p>
<pre><code class="language-text">创建 veth
配置 IP
配置路由
接入 Bridge 或其他数据平面
维护跨节点通信</code></pre><p>Pod IP 通常由 CNI 的 IPAM 模块分配，不是 Kubernetes 或 Linux 自动分配。</p>
<h2>十二、跨节点 Pod 通信</h2>
<p>假设：</p>
<pre><code class="language-text">Node1
PodA = 10.244.1.2

Node2
PodB = 10.244.2.2</code></pre><p>PodA 访问 PodB：</p>
<pre><code class="language-text">10.244.1.2 -&gt; 10.244.2.2</code></pre><p>问题是：</p>
<pre><code class="language-text">Node1 怎么知道 10.244.2.0/24 在 Node2？</code></pre><p>这就是 CNI 要解决的问题。</p>
<h2>十三、VXLAN</h2>
<p>VXLAN 是：</p>
<pre><code class="language-text">L2 Overlay over L3</code></pre><p>意思是：</p>
<pre><code class="language-text">把二层网络封装到三层 IP 网络里面</code></pre><p>原始 Pod 包：</p>
<pre><code class="language-text">SRC 10.244.1.2
DST 10.244.2.2</code></pre><p>经过 VXLAN 封装：</p>
<pre><code class="language-text">Outer IP:
SRC Node1IP
DST Node2IP

UDP

VXLAN Header

Inner IP:
SRC 10.244.1.2
DST 10.244.2.2</code></pre><p>包结构：</p>
<pre><code class="language-text">Outer Ethernet
Outer IP
UDP
VXLAN
Inner Ethernet
Inner IP
TCP
HTTP</code></pre><p>中间物理网络只看到：</p>
<pre><code class="language-text">Node1IP -&gt; Node2IP</code></pre><p>Node2 收到后解 VXLAN，再把原始 Pod 包送给 PodB。</p>
<h2>十四、flannel.1 是什么</h2>
<p><code>flannel.1</code> 不是进程。</p>
<p>它是 Linux VXLAN 网络设备。</p>
<p>类似：</p>
<pre><code class="language-text">eth0
docker0
cni0
vethxxx</code></pre><p>路由表里：</p>
<pre><code class="language-text">10.244.2.0/24 dev flannel.1</code></pre><p>意思是：</p>
<pre><code class="language-text">去 10.244.2.0/24 的流量
交给 flannel.1 这个 VXLAN 设备处理</code></pre><p>然后 Linux 内核负责 VXLAN 封装。</p>
<p><code>flanneld</code> 才是进程，它负责：</p>
<pre><code class="language-text">创建 flannel.1
写路由表
维护 Node 和 PodCIDR 的映射</code></pre><h2>十五、Service 的本质</h2>
<p>Service 提供稳定访问入口。</p>
<p>例如：</p>
<pre><code class="language-text">Service IP = 10.96.0.10</code></pre><p>但这个 IP 通常不是某张真实网卡上的 IP。</p>
<p>Service 本质是：</p>
<pre><code class="language-text">虚拟 IP
+
iptables / IPVS 转发规则</code></pre><h2>十六、kube-proxy</h2>
<p><code>kube-proxy</code> 监听：</p>
<pre><code class="language-text">Service
Endpoint / EndpointSlice</code></pre><p>然后生成转发规则。</p>
<p>例如：</p>
<pre><code class="language-text">10.96.0.10:8080
↓ DNAT
10.244.1.2:8080</code></pre><p>所以访问 Service 时，真实过程是：</p>
<pre><code class="language-text">访问 Service IP
↓
iptables / IPVS 命中规则
↓
DNAT 到某个后端 Pod IP
↓
通过 CNI 网络转发到目标 Pod</code></pre><h2>十七、CoreDNS</h2>
<p>Pod 内访问服务名：</p>
<pre><code class="language-bash">curl user-service</code></pre><p>解析过程：</p>
<pre><code class="language-text">user-service
↓
CoreDNS
↓
Service ClusterIP
↓
kube-proxy
↓
Pod IP</code></pre><h2>十八、Ingress</h2>
<p>Ingress 是规则，不是真正的网关进程。</p>
<p>真正处理流量的是 Ingress Controller，比如：</p>
<pre><code class="language-text">Nginx Ingress Controller
Traefik
APISIX
Kong</code></pre><p>外部请求链路：</p>
<pre><code class="language-text">Browser
↓
LoadBalancer / NodePort
↓
Ingress Controller
↓
Service
↓
Pod</code></pre><p>Ingress Controller 通常是七层反向代理，会解析：</p>
<pre><code class="language-text">Host
Path
TLS
HTTP Header</code></pre><h2>十九、完整链路总结</h2>
<p>Linux 网络基础：</p>
<pre><code class="language-text">MAC
↓
ARP
↓
交换机
↓
路由器
↓
路由表
↓
NAT</code></pre><p>容器网络基础：</p>
<pre><code class="language-text">Namespace
↓
veth
↓
Bridge
↓
IPAM
↓
Docker 网络</code></pre><p>Kubernetes 网络：</p>
<pre><code class="language-text">Pod
↓
CNI
↓
PodCIDR
↓
cni0 / route / eBPF
↓
VXLAN / BGP
↓
Service
↓
CoreDNS
↓
Ingress</code></pre><h2>二十、最终结论</h2>
<p>Kubernetes 网络不是凭空出现的新体系。</p>
<p>它本质是：</p>
<pre><code class="language-text">Linux 网络能力
+
CNI 自动化
+
Kubernetes 声明式编排</code></pre><p>更具体地说：</p>
<pre><code class="language-text">Kubernetes 网络
=
Namespace
+
veth
+
Bridge
+
Route
+
NAT
+
iptables / IPVS
+
VXLAN / BGP / eBPF
+
CNI</code></pre><p>理解 Kubernetes 网络，正确路径不是先背 Pod、Service、Ingress，而是先理解：</p>
<pre><code class="language-text">Linux 如何转发一个包
容器如何拥有自己的网络栈
Pod 如何通过 CNI 接入网络
跨节点 Pod 如何通过 VXLAN 或路由互通
Service 如何通过 DNAT 找到后端 Pod
Ingress 如何作为七层入口转发流量</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/linux-kubernetes-pod#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233519</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>一键配置高效终端：zsh + Oh My Zsh + powerlevel10k（零配置上手）</title>
    <link>https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k</link>
    <pubDate>Fri, 03 Apr 2026 05:42:44 GMT</pubDate>
    <description>一、完整脚本，复制到bash终端运行

#!/usr/bin/env bash
set -e

ec</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k'>https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k</a></blockquote>
          <h1>一、完整脚本，复制到bash终端运行</h1>
<pre><code class="language-bash id="zsh-setup-script"">#!/usr/bin/env bash
set -e

echo "==&gt; Setup zsh + Oh My Zsh + powerlevel10k"

# 1. zsh
if ! command -v zsh &gt;/dev/null 2&gt;&1; then
  echo "Install zsh..."
  brew install zsh
fi

# 2. oh-my-zsh
if [ ! -d "$HOME/.oh-my-zsh" ]; then
  echo "Install oh-my-zsh..."
  sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
fi

ZSH_CUSTOM=${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}

# 3. plugins
install_plugin () {
  if [ ! -d "$ZSH_CUSTOM/plugins/$1" ]; then
    git clone --depth=1 "$2" "$ZSH_CUSTOM/plugins/$1"
  fi
}

install_plugin zsh-autosuggestions https://github.com/zsh-users/zsh-autosuggestions
install_plugin zsh-syntax-highlighting https://github.com/zsh-users/zsh-syntax-highlighting

# 4. theme
if [ ! -d "$ZSH_CUSTOM/themes/powerlevel10k" ]; then
  git clone --depth=1 https://github.com/romkatv/powerlevel10k \
    "$ZSH_CUSTOM/themes/powerlevel10k"
fi

# 5. tools
if command -v brew &gt;/dev/null 2&gt;&1; then
  brew install fzf zoxide &gt;/dev/null 2&gt;&1 || true
  $(brew --prefix)/opt/fzf/install --all &gt;/dev/null 2&gt;&1 || true
fi

# 6. config
cat &gt; ~/.zshrc &lt;&lt;'EOF'
export ZSH="$HOME/.oh-my-zsh"

ZSH_THEME="powerlevel10k/powerlevel10k"

plugins=(
  git
  zsh-autosuggestions
  zsh-syntax-highlighting
)

source $ZSH/oh-my-zsh.sh

command -v zoxide &gt;/dev/null 2&gt;&1 && eval "$(zoxide init zsh)"
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh
EOF

# 7. default shell
if [ "$SHELL" != "$(which zsh)" ]; then
  chsh -s "$(which zsh)"
fi

echo "Done. Restart terminal or run: source ~/.zshrc"
echo "Run: p10k configure"</code></pre><hr>
<h2>包含</h2>
<ul>
<li>zsh</li>
<li>Oh My Zsh</li>
<li>powerlevel10k</li>
<li>autosuggestions / syntax-highlighting</li>
<li>fzf / zoxide</li>
</ul>
<hr>
<h2>使用</h2>
<pre><code class="language-bash id="usage-demo""># 自动补全
git → 按 →

# 历史搜索
Ctrl + R

# 跳目录
z project

# git
gst / gco / gp</code></pre><hr>
<h2>初始化主题</h2>
<pre><code class="language-bash id="p10k-init"">p10k configure</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233518</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>不想再用 puppeteer 了，我写了个 Markdown 渲染引擎</title>
    <link>https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer</link>
    <pubDate>Thu, 02 Apr 2026 08:48:26 GMT</pubDate>
    <description>做过「Markdown → 图片」的，应该都踩过这个坑：

Markdown → HTML → 浏览</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer'>https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer</a></blockquote>
          <p>做过「Markdown → 图片」的，应该都踩过这个坑：</p>
<pre><code class="language-text">Markdown → HTML → 浏览器 → 截图</code></pre><p>然后你就会遇到：</p>
<ul>
<li>chromium 很重（部署麻烦）</li>
<li>分页不稳定（每次结果都可能不一样）</li>
<li>批量渲染性能很差</li>
<li>serverless 环境基本不好搞
我最近有点受不了这套链路，就自己写了一个：
<strong>marknative</strong></li>
</ul>
<hr>
<h2>核心思路（和常规方案完全不一样）</h2>
<p>不走 HTML，不走 DOM，不走浏览器。</p>
<p>而是：</p>
<pre><code class="language-text">Markdown → AST → 自定义文档模型 → layout → 分页 → canvas 绘制</code></pre><p>简单理解：</p>
<blockquote>
<p>把“浏览器排版”这件事，自己实现了一套极简版本（只针对 Markdown）。</p>
</blockquote>
<hr>
<h2>能干什么？</h2>
<h3>1. 直接输出 PNG / SVG</h3>
<pre><code class="language-ts">import { renderMarkdown } from 'marknative'

const pages = await renderMarkdown('# Hello')

await Bun.write('page-1.png', pages[0].data)</code></pre><p>不需要：</p>
<ul>
<li>puppeteer</li>
<li>headless chrome</li>
<li>截图</li>
</ul>
<hr>
<h3>2. 天然支持分页（而且是稳定的）</h3>
<p>默认就是：</p>
<ul>
<li>1080 × 1440</li>
<li>类似卡片比例
而且分页是 deterministic 的：</li>
<li>同一份 Markdown</li>
<li>任意环境</li>
<li>渲染结果一致</li>
</ul>
<hr>
<h3>3. 适合批量生成内容</h3>
<p>比如：</p>
<ul>
<li>小红书卡片</li>
<li>技术文章配图</li>
<li>AI 自动生成内容图
相比 puppeteer：</li>
<li>启动更快</li>
<li>内存更低</li>
<li>更容易横向扩展</li>
</ul>
<hr>
<h2>这个方向值得做</h2>
<p>本质上，这类需求其实是：</p>
<blockquote>
<p>“结构化文本 → 可控排版 → 图像输出”
但浏览器：</p>
</blockquote>
<ul>
<li>太通用（为网页设计）</li>
<li>不可控（CSS + layout 太复杂）</li>
<li>成本太高
而 Markdown 场景：</li>
<li>结构固定</li>
<li>可约束</li>
<li>非常适合做“专用排版引擎”</li>
</ul>
<hr>
<h2>技术点（简单说几个有意思的）</h2>
<ul>
<li>用 <code>micromark</code> + <code>mdast</code> 做解析</li>
<li>自己实现 block / inline layout</li>
<li>用 <code>skia-canvas</code> 直接绘制</li>
<li>layout 和 render 完全解耦
所以理论上可以</li>
<li>换 renderer（SVG / PDF / WebGL）</li>
<li>做自定义主题系统</li>
<li>做可视化编辑器（未来）</li>
</ul>
<hr>
<h2>项目地址</h2>
<p>👉 <a href="https://github.com/liyown/marknative">https://github.com/liyown/marknative</a></p>
<hr>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233517</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Claude 风格的流式 UI，到底是怎么做出来的</title>
    <link>https://liuyaowen.cn/posts/default/20260317</link>
    <pubDate>Tue, 17 Mar 2026 15:55:50 GMT</pubDate>
    <description>claude 最近更新了交互是 UI，可以在聊天框流式输出可交互的页面 claude：



同时看</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/20260317'>https://liuyaowen.cn/posts/default/20260317</a></blockquote>
          <p>claude 最近更新了交互是 UI，可以在聊天框流式输出可交互的页面<a href="https://x.com/claudeai/status/2032124273587077133"> claude</a>：</p>
<p></p>
<p>同时看到一个叫 <code>Generative-UI-MCP</code> 的项目，作者的想法很直接：用 MCP 协议把 Claude 能生成可交互 UI 这套东西复刻出来。</p>
<p>这个项目本身不复杂，但它把一件平时很难说清楚的事拆开了：Claude 这种交互式 UI，真正新在哪里；它为什么不像“AI 帮你写了一段前端代码”那么简单；以及它背后最先要解决的，到底是渲染问题，还是协议问题。</p>
<p>我后来又对着一段真实的 SSE 流式输出日志，把整个过程重新拆了一遍。两边对起来之后，会更清楚：Claude 风格的流式 UI，本质上不是“模型输出 HTML”，而是“模型持续输出一个宿主能够可靠消费的 UI 协议”。</p>
<p>这篇文章想讲的，就是这件事。</p>
<h2>它是一个可持续交互UI 页面</h2>
<p>Claude 这波交互式 UI / Artifacts，真正新的地方不是“AI 生成了一个页面”，那个早就有人做过了。新的地方在于：生成出来的东西还能继续用，还能继续跟模型交互，还能挂工具、更新状态。</p>
<p>这跟“AI 写了段前端代码，你复制出去跑”是完全不同的东西。</p>
<p>以前那种方式，模型是起点，生成完就结束了。现在这种方式，模型是这个 UI 会话里的持续参与者。用户在界面上的操作可以再回到模型，模型返回的新内容可以局部更新界面，来回转。</p>
<p>这个闭环大概长这样：</p>
<pre class="mermaid">
sequenceDiagram

actor U as 用户

participant UI as Widget UI

participant Host as 宿主 / Host

participant Tool as 工具 / API

participant LLM as 模型 / LLM



U->>UI: 点击、输入、操作

UI->>Host: emit(action, payload)



alt 前端可直接处理

Host->>UI: 更新本地状态

else 需要调工具

Host->>Tool: 调用业务接口

Tool-->>Host: 返回结果

Host->>UI: 局部更新

else 需要再问模型

Host->>LLM: 当前状态 + action

LLM-->>Host: 新内容 / 新 widget

Host->>UI: 增量插入或局部替换

end
</pre><p>这个循环跑得起来，才是真正的“交互式”。带几个按钮的 HTML 不叫交互式，事件能回流才叫。</p>
<h2>一段真实的流式输出，能把这件事看得很清楚</h2>
<p>如果看一段真实的 SSE 流式输出，会发现模型并不是一次性吐出一个完整页面，而是在流里持续输出不同类型的内容块，前端边收边拼，拼完再交给对应工具渲染。</p>
<p>拆开之后，大概是五步。</p>
<h3>第一步：先加载 UI 生成规范</h3>
<p>模型一开始并没有直接生成 widget，而是先调用了一个类似 <code>visualize:read_me</code> 的工具，输入参数非常短：</p>
<pre><code class="language-json">
{

"modules": ["diagram", "interactive"]

}
</code></pre><p>这一步很关键。它说明模型在真正开始“画界面”之前，先去拿一份运行时 UI 规范。也就是说，生成不是裸奔的，模型先要知道这次该遵守什么规则。</p>
<h3>第二步：工具返回一整套设计系统和流式输出约束</h3>
<p>这份返回内容里，有几段特别关键。</p>
<p>先是模块说明：</p>
<pre><code class="language-text">
Call read_me again with the modules parameter to load detailed guidance:

- `diagram` — SVG flowcharts, structural diagrams, illustrative diagrams

- `mockup` — UI mockups, forms, cards, dashboards

- `interactive` — interactive explainers with controls

- `chart` — charts, data analysis, geographic maps (Chart.js, D3 choropleth)

- `art` — illustration and generative art
</code></pre><p>然后是角色定义：</p>
<pre><code class="language-text">
You create rich visual content — SVG diagrams/illustrations and HTML interactive widgets — that renders inline in conversation. The best output feels like a natural extension of the chat.
</code></pre><p>接着是它最关键的几条约束：</p>
<pre><code class="language-text">
### Philosophy

- Seamless: Users shouldn't notice where claude.ai ends and your widget begins.

- Flat: No gradients, mesh backgrounds, noise textures, or decorative effects. Clean flat surfaces.

- Compact: Show the essential inline. Explain the rest in text.

- Text goes in your response, visuals go in the tool.
</code></pre><p>还有专门面向流式渲染的顺序规则：</p>
<pre><code class="language-text">
### Streaming

Output streams token-by-token. Structure code so useful content appears early.

- HTML: &lt;style&gt; (short) → content HTML → &lt;script&gt; last.

- SVG: &lt;defs&gt; (markers) → visual elements immediately.

- Prefer inline style="..." over &lt;style&gt; blocks.

- Gradients, shadows, and blur flash during streaming DOM diffs. Use solid flat fills instead.
</code></pre><p>如果只看表面，这像一份设计规范；但从运行角度看，它其实更像一份“让模型输出稳定 UI 消息”的生成协议。</p>
<p>它在做几件事：</p>
<ul>
<li><p>规定什么该出现在工具里，什么该出现在自然语言里</p>
</li>
<li><p>规定代码要按什么顺序流式吐出来</p>
</li>
<li><p>规定哪些视觉效果会破坏流式体验，所以禁止使用</p>
</li>
<li><p>规定组件必须适配宿主环境，比如 CSS 变量、深色模式、受控脚本能力</p>
</li>
</ul>
<p>也就是说，这不是“给模型一些审美建议”，而是在给模型划一条窄轨道。</p>
<h2>真正的关键，不是 HTML，而是模型输出的结构</h2>
<p>随后模型开始调用另一个工具，比如 <code>visualize:show_widget</code>。这一段流最容易让人误解，因为它看起来像一堆零碎碎片：</p>
<pre><code class="language-text">
event: content_block_delta

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"-2-2L"}}
</code></pre><p>单看这种片段，几乎没有可读性。但它们其实不是乱码，而是工具调用参数的一部分。宿主会把同一个 block 下不断到来的 <code>partial_json</code> 拼起来，最后还原成一个完整 JSON。</p>
<p>像这次，重组之后大概是这样：</p>
<pre><code class="language-json">
{

"title": "ui_icons_outline",

"loading_messages": [

"Sketching icon paths...",

"Adding hover magic...",

"Lining up the grid..."

],

"i_have_seen_read_me": true,

"widget_code": "..."

}
</code></pre><p>这里面每个字段都很有意思。</p>
<p><code>title</code> 是这次 widget 的标识。<code>loading_messages</code> 不是装饰，而是在把“等待”转成可感知的生成过程。<code>i_have_seen_read_me</code> 则像一个状态确认，表明模型是在已经读过规范的前提下生成的。</p>
<p>而真正的界面，都放进了 <code>widget_code</code>。</p>
<p>这一步揭示了流式 UI 的核心事实：模型不是直接输出最终页面，而是在输出一个宿主可以消费的 UI 消息。</p>
<h2>为什么 <code>Generative-UI-MCP</code> 这个项目看起来很小，却很有价值</h2>
<p>我原本以为要复刻 Claude 交互式 UI，需要很多东西：自定义渲染器、状态管理、完整前端运行时、组件库、DSL。</p>
<p>结果 <code>Generative-UI-MCP</code> 的核心极简得有点反直觉：</p>
<ul>
<li><p>一个 <code>load_ui_guidelines</code> 工具，按需给模型加载 UI 生成规范</p>
</li>
<li><p>一个 system prompt 资源，把最基础的输出约束提前注入</p>
</li>
</ul>
<p>没有大而全的组件系统，也没有复杂 DSL。</p>
<p>这个取舍反而很能说明问题：复刻 Claude 交互式 UI，最先要解决的不是“怎么渲染”，而是“怎么让模型输出一个宿主能稳定消费的结构”。渲染反而是后面的事。</p>
<h2>所以这件事首先是协议问题，不是渲染问题</h2>
<p>Claude 的交互式 UI 有一个很强的体验特征：widget 的出现是稳定、可预期的。不会这次变成代码块，下次又变成自然语言夹 HTML。</p>
<p>要做到这件事，不能靠模型“自觉”，只能靠协议。</p>
<p><code>Generative-UI-MCP</code> 暴露出来的，正是这层东西：</p>
<ul>
<li><p>widget 要用专用 fence 包裹</p>
</li>
<li><p>fence 里必须是结构化 JSON</p>
</li>
<li><p><code>widget_code</code> 字段里装 HTML 或 SVG</p>
</li>
<li><p>解释文本必须写在 widget block 外面</p>
</li>
<li><p>多个 widget 要拆成多个 block</p>
</li>
<li><p>输出顺序得适合流式渲染</p>
</li>
</ul>
<p>这些约束堆起来，本质上已经非常接近一个轻量的 UI 消息协议。</p>
<p>宿主做的事情，本质上就是在这些消息之间路由。</p>
<h2>为什么规范一定要按需加载，而不是一次性全塞进 prompt</h2>
<p>复刻项目把 UI 规范拆成了几个模块：<code>interactive</code>、<code>chart</code>、<code>mockup</code>、<code>diagram</code>、<code>art</code>，需要什么再加载什么。</p>
<p>这不只是为了省 token，更关键的是：不同 UI 类型的约束本来就不一样。</p>
<ul>
<li><p>图表有图表的规则</p>
</li>
<li><p>表单有表单的规则</p>
</li>
<li><p>mockup 有 mockup 的规则</p>
</li>
<li><p>diagram 有 diagram 的规则</p>
</li>
<li><p>art 又是另一套生成方式</p>
</li>
</ul>
<p>如果全塞进一个大 prompt，模型会被很多无关约束污染。按需加载的价值在于：只有在真正要生成某类 UI 时，模型才拿到那一类规则，输出才更稳定。</p>
<p>这和前面真实流里先传：</p>
<pre><code class="language-json">
{

"modules": ["diagram", "interactive"]

}
</code></pre><p>其实是同一个思路。</p>
<h2>流式的难点，从来不是“更快”，而是“边流边分帧”</h2>
<p>很多人对流式的理解停留在“更快显示”。但从真实输出和复刻项目都能看出来，宿主真正要解决的是 parser。</p>
<p>宿主不能只是把 token 一个个打印出来。它必须知道：</p>
<ul>
<li><p>当前是普通文本，还是 widget block</p>
</li>
<li><p>当前是在输出 block 起始，还是中间片段</p>
</li>
<li><p>JSON 有没有完整闭合</p>
</li>
<li><p>什么时候可以直接显示文本</p>
</li>
<li><p>什么时候该进入收集模式</p>
</li>
<li><p>什么时候该把完整 <code>widget_code</code> 交给渲染器</p>
</li>
</ul>
<p>整个过程大概是这样：</p>
<pre class="mermaid">
sequenceDiagram

participant LLM as 模型输出 (stream)

participant Parser as Stream Parser

participant Renderer as Widget 渲染器

participant UI as UI 界面



LLM->>Parser: token chunk 1..n

Parser->>UI: 普通文本直接显示



Note right of Parser: 识别到 widget fence<br/>切换到收集模式

Parser->>Parser: 持续收集 JSON block

Parser->>Renderer: 完整 JSON<br/>(title + widget_code)

Renderer->>UI: 挂载 HTML / SVG widget

Note right of UI: 一边生成，一边可见
</pre><p>Claude 那种“widget 自然浮现”的感觉，技术上并不是魔法，而是 parser 在做边流边分帧。</p>
<h2>为什么连 <code>&lt;defs&gt;</code>、<code>style</code> 顺序、阴影这些细节都要管</h2>
<p>第一次看到这类规范，很容易觉得它管得太细：</p>
<ul>
<li><p>SVG 里 <code>&lt;defs&gt;</code> 要先于图形</p>
</li>
<li><p>HTML 里 <code>style</code> 在前、<code>script</code> 在后</p>
</li>
<li><p>尽量避免渐变、阴影、模糊</p>
</li>
</ul>
<p>但这些规则不是在管审美，而是在管用户看到的每一帧是否合法。</p>
<p>原因很简单：</p>
<ul>
<li><p><code>&lt;defs&gt;</code> 还没到，图形先出来，marker 和 clipPath 会先错后正</p>
</li>
<li><p><code>style</code> 太晚到，用户会先看到裸 UI，再看到样式突然补齐</p>
</li>
<li><p>渐变、模糊、阴影在流式 patch 过程中更容易出现跨帧不一致</p>
</li>
</ul>
<p>Claude 输出 widget 很少出现明显抖动，不只是因为模型更强，也因为这套约束把中间态的不稳定性压下去了。</p>
<h2>从一个真实 widget 看，这套方法到底偏向什么样的前端</h2>
<p>在那段真实输出里，模型最终生成的是一个“25 个常用 UI 线条图标”的交互面板。它按类别展示图标，点击可以高亮，并在底部给出反馈。</p>
<p>从生成出来的 <code>widget_code</code> 可以看出几个很鲜明的取舍。</p>
<p>第一，布局非常简单，核心是稳定的 grid，而不是复杂的响应式技巧。</p>
<p>第二，样式极轻，全部基于宿主给的 CSS 变量，不写死颜色，天然适配深色模式。</p>
<p>第三，图标直接内联 SVG，不依赖图片资源，这样既容易流式输出，也容易在 hover 和 active 态切换颜色。</p>
<p>第四，JS 很短，只做本地交互，不做复杂状态管理，不请求网络，不引入框架。</p>
<p>这说明这类流式 UI 更像“会话中的即时交互壳”，不是完整前端应用。复杂逻辑交给模型，局部互动留在前端。</p>
<p>它很适合：</p>
<ul>
<li><p>图标面板</p>
</li>
<li><p>对比卡片</p>
</li>
<li><p>轻量筛选器</p>
</li>
<li><p>小型图表</p>
</li>
<li><p>交互式解释器</p>
</li>
<li><p>内嵌 mockup</p>
</li>
</ul>
<p>但不太适合：</p>
<ul>
<li><p>超复杂业务表单</p>
</li>
<li><p>大型多页应用</p>
</li>
<li><p>重状态后台系统</p>
</li>
<li><p>强实时协作编辑器</p>
</li>
</ul>
<p>因为它的优势是即时生成、即时嵌入、即时互动，不是长期运行的大型应用壳。</p>
<h2>真实流里最后还有一个很重要的信号：文本和 UI 必须分工</h2>
<p>在工具把 widget 渲染完之后，系统又返回了一句提示：</p>
<pre><code class="language-text">
Content rendered and shown to the user. Please do not duplicate the shown content in text because it's already visually represented.
</code></pre><p>这句提示的价值很大。它明确告诉模型：已经渲染出来的东西，不要再重复讲一遍。</p>
<p>随后模型补上的自然语言也很克制，只做三件事：</p>
<ul>
<li><p>概括这个 widget 是什么</p>
</li>
<li><p>告诉用户如何操作</p>
</li>
<li><p>提示用户下一步还能让模型做什么</p>
</li>
</ul>
<p>这和前面 readme 里的那句：</p>
<pre><code class="language-text">
Text goes in your response, visuals go in the tool.
</code></pre><p>正好闭环。</p>
<p>也就是说，Claude 风格的流式 UI，不只是“会渲染 widget”，它还在管理文本和视觉之间的职责边界。</p>
<h2><code>Generative-UI-MCP</code> 看不到的部分，反而是产品化最难的部分</h2>
<p>老实说，看完这个复刻项目，会更清楚原版系统有哪些东西不是只靠协议就能补齐的。</p>
<h3>1. 沙箱</h3>
<p>模型生成的 HTML/JS 不能直接裸跑。必须有 iframe 隔离、白名单 CDN、脚本能力限制、资源权限边界。</p>
<p>否则模型只要生成一段恶意脚本，宿主就会出问题。</p>
<h3>2. action 协议</h3>
<p>用户点击之后发生什么，不能靠模型随便写 <code>onclick</code> 并自由决定逻辑。成熟设计更像是宿主先定义一套统一 action schema，比如：</p>
<ul>
<li><p><code>filter_changed</code></p>
</li>
<li><p><code>submit_form</code></p>
</li>
<li><p><code>request_refresh</code></p>
</li>
<li><p><code>select_item</code></p>
</li>
</ul>
<p>widget 只发动作，宿主决定本地处理、调工具，还是再问模型。</p>
<h3>3. 增量 patch</h3>
<p>Claude 在多轮对话里更新 widget，很多时候不是整块重生成，而是局部更新。这件事要求宿主维护状态，也要求模型知道什么时候该返回 patch，什么时候该返回完整替换。</p>
<p>demo 和产品级体验之间差得最多的地方，大概就在这里。</p>
<h2>真正值得记住的一句话</h2>
<p>看 <code>Generative-UI-MCP</code> 最大的收获，不是学到某个新技巧，而是更清楚地意识到：</p>
<p>做交互式 UI，这件事从来不是先解决渲染，而是先解决协议。</p>
<p>协议稳定了，才有后面的这些东西：</p>
<ul>
<li><p>流式 parser</p>
</li>
<li><p>widget 渲染</p>
</li>
<li><p>沙箱执行</p>
</li>
<li><p>事件回流</p>
</li>
<li><p>工具挂载</p>
</li>
<li><p>增量更新</p>
</li>
</ul>
<p>Claude 把这条链路基本跑通了，所以它用起来不像 demo。<code>Generative-UI-MCP</code> 把这条链路最前面的那段逻辑开源出来了，所以这件事第一次变得足够可理解、可讨论、可拆解。</p>
<p>回头再看那些看似零碎的流式片段，尤其是不断出现的 <code>input_json_delta</code>、<code>widget_code</code>、<code>tool_use</code>，就不会再觉得它们只是噪音。它们其实正是这整套生成式 UI 协议在运行时留下的痕迹。</p>
<h2>附录：这次流里真实出现的原始提示词</h2>
<p>下面这部分不是整理后的模板，而是从真实流式输出里还原出的原始提示词和规范文本。</p>
<h3>1. 模块选择</h3>
<pre><code class="language-json">
{

"modules": [

"diagram",

"interactive"

]

}
</code></pre><h3>2. <code>visualize:read_me</code> 返回的规范文本</h3>
<pre><code class="language-text">
# Imagine — Visual Creation Suite



## Modules

Call read_me again with the modules parameter to load detailed guidance:

- `diagram` — SVG flowcharts, structural diagrams, illustrative diagrams

- `mockup` — UI mockups, forms, cards, dashboards

- `interactive` — interactive explainers with controls

- `chart` — charts, data analysis, geographic maps (Chart.js, D3 choropleth)

- `art` — illustration and generative art

Pick the closest fit. The module includes all relevant design guidance.



**Complexity budget — hard limits:**

- Box subtitles: ≤5 words. Detail goes in click-through (`sendPrompt`) or the prose below — not the box.

- Colors: ≤2 ramps per diagram. If colors encode meaning (states, tiers), add a 1-line legend. Otherwise use one neutral ramp.

- Horizontal tier: ≤4 boxes at full width (~140px each). 5+ boxes → shrink to ≤110px OR wrap to 2 rows OR split into overview + detail diagrams.



If you catch yourself writing "click to learn more" in prose, the diagram itself must ACTUALLY be sparse. Don't promise brevity then front-load everything.



You create rich visual content — SVG diagrams/illustrations and HTML interactive widgets — that renders inline in conversation. The best output feels like a natural extension of the chat.



## Core Design System



These rules apply to ALL use cases.



### Philosophy

- **Seamless**: Users shouldn't notice where claude.ai ends and your widget begins.

- **Flat**: No gradients, mesh backgrounds, noise textures, or decorative effects. Clean flat surfaces.

- **Compact**: Show the essential inline. Explain the rest in text.

- **Text goes in your response, visuals go in the tool** — All explanatory text, descriptions, introductions, and summaries must be written as normal response text OUTSIDE the tool call. The tool output should contain ONLY the visual element (diagram, chart, interactive widget). Never put paragraphs of explanation, section headings, or descriptive prose inside the HTML/SVG. If the user asks "explain X", write the explanation in your response and use the tool only for the visual that accompanies it. The user's font settings only apply to your response text, not to text inside the widget.



### Streaming

Output streams token-by-token. Structure code so useful content appears early.

- **HTML**: `&lt;style&gt;` (short) → content HTML → `&lt;script&gt;` last.

- **SVG**: `&lt;defs&gt;` (markers) → visual elements immediately.

- Prefer inline `style="..."` over `&lt;style&gt;` blocks — inputs/controls must look correct mid-stream.

- Keep `&lt;style&gt;` under ~15 lines. Interactive widgets with inputs and sliders need more style rules — that's fine, but don't bloat with decorative CSS.

- Gradients, shadows, and blur flash during streaming DOM diffs. Use solid flat fills instead.



### Rules

- No `` or `/* comments */` (waste tokens, break streaming)

- No font-size below 11px

- No emoji — use CSS shapes or SVG paths

- No gradients, drop shadows, blur, glow, or neon effects

- No dark/colored backgrounds on outer containers (transparent only — host provides the bg)

- **Typography**: The default font is Anthropic Sans. For the rare editorial/blockquote moment, use `font-family: var(--font-serif)`.

- **Headings**: h1 = 22px, h2 = 18px, h3 = 16px — all `font-weight: 500`. Heading color is pre-set to `var(--color-text-primary)` — don't override it. Body text = 16px, weight 400, `line-height: 1.7`. **Two weights only: 400 regular, 500 bold.** Never use 600 or 700 — they look heavy against the host UI.

- **Sentence case** always. Never Title Case, never ALL CAPS. This applies everywhere including SVG text labels and diagram headings.

- **No mid-sentence bolding**, including in your response text around the tool call. Entity names, class names, function names go in `code style` not **bold**. Bold is for headings and labels only.

- The widget container is `display: block; width: 100%`. Your HTML fills it naturally — no wrapper div needed. Just start with your content directly. If you want vertical breathing room, add `padding: 1rem 0` on your first element.

- Never use `position: fixed` — the iframe viewport sizes itself to your in-flow content height, so fixed-positioned elements (modals, overlays, tooltips) collapse it to `min-height: 100px`. For modal/overlay mockups: wrap everything in a normal-flow `<div>` and put the modal inside — it's a faux viewport that actually contributes layout height.

- No DOCTYPE, `&lt;html&gt;`, `&lt;head&gt;`, or `&lt;body&gt;` — just content fragments.

- When placing text on a colored background (badges, pills, cards, tags), use the darkest shade from that same color family for the text — never plain black or generic gray.

- **Corners**: use `border-radius: var(--border-radius-md)` (or `-lg` for cards) in HTML. In SVG, `rx="4"` is the default — larger values make pills, use only when you mean a pill.

- **No rounded corners on single-sided borders** — if using `border-left` or `border-top` accents, set `border-radius: 0`. Rounded corners only work with full borders on all sides.

- **No titles or prose inside the tool output** — see Philosophy above.

- **Icon sizing**: When using emoji or inline SVG icons, explicitly set `font-size: 16px` for emoji or `width: 16px; height: 16px` for SVG icons. Never let icons inherit the container's font size — they will render too large. For larger decorative icons, use 24px max.

- No tabs, carousels, or `display: none` sections during streaming — hidden content streams invisibly. Show all content stacked vertically. (Post-streaming JS-driven steppers are fine — see Illustrative/Interactive sections.)

- No nested scrolling — auto-fit height.

- Scripts execute after streaming — load libraries via `&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/..."&gt;` (UMD globals), then use the global in a plain `&lt;script&gt;` that follows.

- **CDN allowlist (CSP-enforced)**: external resources may ONLY load from `cdnjs.cloudflare.com`, `esm.sh`, `cdn.jsdelivr.net`, `unpkg.com`. All other origins are blocked by the sandbox — the request silently fails.



### CSS Variables

**Backgrounds**: `--color-background-primary` (white), `-secondary` (surfaces), `-tertiary` (page bg), `-info`, `-danger`, `-success`, `-warning`

**Text**: `--color-text-primary` (black), `-secondary` (muted), `-tertiary` (hints), `-info`, `-danger`, `-success`, `-warning`

**Borders**: `--color-border-tertiary` (0.15α, default), `-secondary` (0.3α, hover), `-primary` (0.4α), semantic `-info/-danger/-success/-warning`

**Typography**: `--font-sans`, `--font-serif`, `--font-mono`

**Layout**: `--border-radius-md` (8px), `--border-radius-lg` (12px — preferred for most components), `--border-radius-xl` (16px)

All auto-adapt to light/dark mode. For custom colors in HTML, use CSS variables.



**Dark mode is mandatory** — every color must work in both modes:

- In SVG: use the pre-built color classes (`c-blue`, `c-teal`, `c-amber`, etc.) for colored nodes — they handle light/dark mode automatically. Never write `&lt;style&gt;` blocks for colors.

- In SVG: every `&lt;text&gt;` element needs a class (`t`, `ts`, `th`) — never omit fill or use `fill="inherit"`. Inside a `c-{color}` parent, text classes auto-adjust to the ramp.

- In HTML: always use CSS variables (--color-text-primary, --color-text-secondary) for text. Never hardcode colors like color: #333 — invisible in dark mode.

- Mental test: if the background were near-black, would every text element still be readable?



### sendPrompt(text)

A global function that sends a message to chat as if the user typed it. Use it when the user's next step benefits from Claude thinking. Handle filtering, sorting, toggling, and calculations in JS instead.



### Links

`<a href="https://...">` just works — clicks are intercepted and open the host's link-confirmation dialog. Or call `openLink(url)` directly.



## When nothing fits

Pick the closest use case below and adapt. When nothing fits cleanly:

- Default to editorial layout if the content is explanatory

- Default to card layout if the content is a bounded object

- All core design system rules still apply

- Use `sendPrompt()` for any action that benefits from Claude thinking
</code></pre><h3>3. <code>visualize:show_widget</code> 的真实参数</h3>
<pre><code class="language-json">
{

"title": "ui_icons_outline",

"loading_messages": [

"Sketching icon paths...",

"Adding hover magic...",

"Lining up the grid..."

],

"i_have_seen_read_me": true,

"widget_code": "&lt;style&gt;...&lt;/style&gt;<div>...</div>&lt;script&gt;...&lt;/script&gt;"

}
</code></pre><h3>4. 工具渲染后的真实提示</h3>
<pre><code class="language-text">
Content rendered and shown to the user. Please do not duplicate the shown content in text because it's already visually represented.



[This tool call rendered an interactive widget in the chat. The user can already see the result — do not repeat it in text or with another visualization tool.]
</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/20260317#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233516</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>图像生成怎么突然把“写字”这件事做对了？</title>
    <link>https://liuyaowen.cn/posts/default/20260314</link>
    <pubDate>Sat, 14 Mar 2026 15:17:25 GMT</pubDate>
    <description>最近我有一个很直观的感受：  
这一波图像生成模型，很多已经不再只是“能画出像字的东西”，而是真的开</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/20260314'>https://liuyaowen.cn/posts/default/20260314</a></blockquote>
          <p>最近我有一个很直观的感受：<br>这一波图像生成模型，很多已经不再只是“能画出像字的东西”，而是真的开始<strong>把字写对</strong>了。</p>
<p>这件事其实挺值得停下来想一下。</p>
<p>因为如果你回头看前两年的生图效果，会发现一个很稳定的槽点：<br>画面氛围、构图、光影都已经很强了，但只要图里出现招牌、海报标题、包装文案、UI 文本，基本就会露馅。英文会串字母，中文会缺部件，小字更是一塌糊涂。这个问题当时严重到什么程度？严重到不少关于视觉文本生成的论文，开头第一段就在说：现在的文生图虽然整体 fidelity 很高，但只要把视线移到 text area，破绽就非常明显。 <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:0‡arXiv</a></p>
<p>但最近不太一样了。</p>
<p>我自己看到的一些新结果，已经不只是“偶尔能写对几个字”，而是明显能感觉到：模型在处理标题、标语、场景里的招牌文字时，稳定性比以前高了一大截。于是我就有点好奇：<strong>这事到底是怎么被解决的？</strong><br>难道真的是模型大了、数据多了，所以顺手把文字问题也学会了？还是说，研究界其实已经悄悄换了一套解题思路？</p>
<p>我去翻了一圈近两三年的论文之后，得到的结论还挺明确的：</p>
<p><strong>文字问题不是靠 prompt engineering 慢慢磨出来的，也不是模型“顺便学会”的。它本质上是被单独拿出来，当成一个专门问题重新建模了。</strong>  <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:1‡arXiv</a></p>
<p>这篇文章就想顺着这个好奇心，聊清楚三件事：</p>
<ol>
<li>为什么图像生成一碰到文字就特别容易翻车；  </li>
<li>最近这些方法到底是怎么解决的；  </li>
<li>为什么我越来越觉得，这条路线最后会走向一种 <strong>glyph-first</strong> 的系统，而不是继续指望 prompt 自己长出排版能力。</li>
</ol>
<hr>
<h2>先说最核心的一点：文字不是普通图像内容</h2>
<p>我觉得理解这个问题，第一步不是去看 attention，也不是去看 OCR loss，而是先承认一件事：</p>
<p><strong>文字和“云、树、衣服、墙面纹理”不是同一类对象。</strong></p>
<p>普通图像内容大多是连续的。<br>云稍微糊一点，还是云；木纹稍微歪一点，也还是木纹。<br>但文字不是这样。文字是低容错的离散符号系统，一个字少一笔、错一个结构，可能立刻就不可读了。</p>
<p>这也是为什么早期很多模型会给人一种很微妙的感觉：<br>远看挺像，近看不对。<br>你会觉得“这里好像应该有字”，但认真一看，其实只是某种<strong>很像文字的纹理</strong>，不是可读的文字。</p>
<p>GlyphControl 在 2023 年就把这个问题讲得很直白：视觉文本生成不是普通 T2I 任务的自然延伸，必须引入额外的 glyph 条件信息，才能稳定生成准确文本。AnyText 也有类似判断：即便图像整体质量已经很高，一旦聚焦到文本区域，问题就会立刻暴露出来。 <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:2‡arXiv</a></p>
<pre class="mermaid">flowchart TD
    A[为什么文字特别难] --> B[文字是离散符号]
    A --> C[文字既有语义又有几何]
    A --> D[文字区域通常很小]
    A --> E[文字目标和背景目标并不一致]

    B --> B1[一笔错就可能不可读]
    C --> C1[不仅要知道写什么 还要知道长什么样]
    D --> D1[全图训练时容易被背景梯度淹没]
    E --> E1[背景追求自然连续 文字追求局部精确]</pre><hr>
<h2>为什么以前的模型总是“像有字”，但又写不对？</h2>
<p>如果只从模型结构上看，这个问题其实挺自然的。</p>
<p>传统文生图做条件注入，基本是这条路：</p>
<ul>
<li>图像 latent / patch token 当 Query</li>
<li>文本 token 当 Key / Value</li>
<li>让图像在生成过程中不断去“读”文本条件</li>
</ul>
<p>这个机制对普通语义对齐非常有效。<br>比如 prompt 里有 <code>red car on snow</code>，模型很容易学会：</p>
<ul>
<li>哪些区域该看 <code>car</code></li>
<li>哪些区域该看 <code>snow</code></li>
<li>哪些局部更多受到 <code>red</code> 的影响</li>
</ul>
<p>但问题在于，<strong>文本 token 提供的是语义，不是字形。</strong></p>
<p>token “A” 并不是字母 A 的视觉结构，<br>token “春” 也不是“春”字的具体笔画排列。<br>所以如果系统只吃语义 token，它更容易学会的是：</p>
<blockquote>
<p>这里应该有一段“文字感”</p>
</blockquote>
<p>而不是：</p>
<blockquote>
<p>这里必须是这个字符，而且要笔画对、边界清楚、相邻字符别打架</p>
</blockquote>
<p>这也是为什么过去两三年的论文，几乎都在朝一个方向收敛：</p>
<p><strong>别再只靠 semantic conditioning 了，要把文字从“语言提示”升级成“显式字形条件”。</strong><br>GlyphControl 是这样，AnyText 是这样，FLUX-Text 是这样，TextPixs 也是这样。 <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:3‡arXiv</a></p>
<pre class="mermaid">flowchart LR
    A[仅有语义 token] --> B[告诉模型 这里应该有文字]
    B --> C[优点: 和普通 T2I 兼容]
    B --> D[缺点: 没告诉模型这个字具体长什么样]
    D --> E[结果: 容易生成伪字形/串字/错字]

    F[显式 glyph 条件] --> G[告诉模型 这里应该是这个字符形状]
    G --> H[附带位置/大小/区域信息]
    H --> I[结果: 可读性和可控性明显上升]</pre><hr>
<h2>研究界是怎么一步步把这个问题拆开的？</h2>
<p>如果把近几年的工作串起来看，我觉得它不是“某篇论文突然发明了一个神奇模块”，而是经历了一个挺清楚的演化过程。</p>
<h3>第一阶段：先承认“文字生成”是一个独立问题</h3>
<p>这一阶段最重要的，不是技术细节，而是问题被重新命名了。</p>
<p>GlyphControl 很关键的一点，是明确提出 visual text generation 需要 glyph-conditional control，而不是继续指望 character-aware text encoder 或更大的通用模型自己学会。论文还专门构建了 LAION-Glyph 数据集，并用 OCR-based metrics、CLIP score、FID 来评估结果。这个动作很重要，因为它等于在说：<strong>文字渲染已经值得有自己的一套 benchmark 和指标。</strong>  <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:4‡arXiv</a></p>
<p>AnyText 则把这件事进一步系统化了。它不只是做文字生成，还把多语言文字生成和编辑放进同一个扩散框架里，并引入 AnyWord-3M 数据集和 AnyText-benchmark。论文里说得很清楚：文字区域的问题不能再被当成普通图像 fidelity 的附属现象，它需要独立建模。 <a href="https://arxiv.org/abs/2311.03054?utm_source=chatgpt.com">oai_citation:5‡arXiv</a></p>
<p>换句话说，第一阶段真正发生的事是：</p>
<blockquote>
<p>大家不再问“为什么模型还不会写字”，<br>而开始问“如果把写字当成一个专门任务，我们应该怎么表示它、训练它、评估它？”</p>
</blockquote>
<hr>
<h3>第二阶段：从 semantic-first 走向 glyph-first</h3>
<p>这是我觉得最关键的转折。</p>
<p>以前的做法，本质上是 semantic-first：<br>先给模型一个字符串的语义表示，然后希望它在图像空间里自己把字符外形“悟出来”。</p>
<p>但后来大家慢慢发现，这条路上限不高。<br>因为语义和字形根本不是一回事。你知道一个词的 meaning，不等于你就知道它在图上该怎么写。</p>
<p>所以 GlyphControl 的解法很直接：<br>不要只告诉模型“这里写 SALE”，还要把 <strong>SALE 的 glyph instruction</strong> 明确给它。这样用户不仅能控制文本内容，还能控制位置和大小。 <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:6‡arXiv</a></p>
<p>AnyText 继续沿着这个方向往前走，它的设计里有两个特别重要的模块：</p>
<ul>
<li><strong>Auxiliary latent module</strong>：吃 glyph、position、masked image，生成跟文字相关的 latent feature；</li>
<li><strong>Text embedding module</strong>：借助 OCR 模型去编码 stroke 信息，再把这些 embedding 和 caption embedding 融合起来。</li>
</ul>
<p>这其实已经很能说明问题了：<br>真正有效的文字生成，不是再多喂一点 prompt，而是把<strong>字形、位置、区域</strong>这些原本没被好好表示的东西，变成模型能直接消费的条件。 <a href="https://arxiv.org/abs/2311.03054?utm_source=chatgpt.com">oai_citation:7‡arXiv</a></p>
<p>我自己看到这里的时候，感受挺强的：<br>这已经不是“优化 prompt 理解”了，而是在重新定义输入空间。</p>
<pre class="mermaid">flowchart TD
    A[从 prompt-only 到 glyph-first] --> B[阶段 1 只给字符串语义]
    A --> C[阶段 2 给 glyph + position + region]
    A --> D[阶段 3 再把这些条件深入注入 backbone]

    B --> B1[容易得到像文字的纹理]
    C --> C1[开始能控制字符内容/位置/大小]
    D --> D1[开始追求字符级稳定和场景一致性]</pre><hr>
<h3>第三阶段：问题不只是“有没有 glyph”，而是“glyph 怎么进系统”</h3>
<p>glyph conditioning 成为共识之后，研究重点就开始下沉。</p>
<p>大家不再争论“该不该给 glyph”，而是开始研究：</p>
<ul>
<li>glyph 是当额外输入就够了吗？</li>
<li>还是应该更深地改 backbone？</li>
<li>训练目标是不是也得跟着变？</li>
</ul>
<p>FLUX-Text 是我觉得很典型的一篇。<br>它不是那种特别花哨的“新世界观”，但它很实在。它的思路是：在 FLUX-Fill 这种强 base model 上，用比较轻量的 glyph 和 text embedding 模块增强文字理解与生成，同时尽量保留原有生成能力。更重要的是，它显式提出了 <strong>Regional Text Perceptual Loss</strong>，就是告诉你：<strong>文字区域必须被单独优化。</strong>  <a href="https://arxiv.org/abs/2505.03329?utm_source=chatgpt.com">oai_citation:8‡arXiv</a></p>
<p>这一点其实特别重要。</p>
<p>因为文字区域通常很小，如果训练目标还是全图统一的，那么大部分梯度都来自背景。模型当然会更优先学“把图做漂亮”，而不是“把字写对”。FLUX-Text 的意思某种程度上就是：<br>你不能一边说文字很重要，一边在损失函数里继续拿它当背景噪声的一小块。 <a href="https://arxiv.org/abs/2505.03329?utm_source=chatgpt.com">oai_citation:9‡arXiv</a></p>
<hr>
<h3>第四阶段：从“词级条件”继续下钻到“字符级绑定”</h3>
<p>再往后，问题就会变得更细。</p>
<p>即使你给了 glyph，也不代表字符之间不会互相干扰。<br>很多时候模型出的问题不再是“完全不知道写什么”，而是：</p>
<ul>
<li>相邻字符粘连</li>
<li>一个字符的结构跑到另一个字符那边去</li>
<li>整串文本看起来有大概的样子，但局部字符不稳定</li>
</ul>
<p>这时候 TextPixs 这种工作就很有意思了。</p>
<p>它做了几件非常针对性的事：</p>
<ul>
<li>双流编码：语义文本流 + glyph 视觉流</li>
<li><strong>character-aware attention</strong></li>
<li>OCR-in-the-loop feedback</li>
<li>attention segregation loss</li>
</ul>
<p>这套东西的核心直觉很简单：<br><strong>文字不是词级别对齐就够了，而是要字符级别地对齐。</strong></p>
<p>普通 T2I 里，token 级控制通常已经足够。<br>但文字渲染不一样，字符是最终可读性的最小单位。如果字符之间的 attention 没有被稳定地分开，系统就很容易得到“这是一串字”的整体感觉，却写不对具体每个字。TextPixs 也直接把目标写得很清楚：解决 readable、meaningful、correctly spelled text 的问题。 <a href="https://arxiv.org/abs/2507.06033?utm_source=chatgpt.com">oai_citation:10‡arXiv</a></p>
<pre class="mermaid">flowchart LR
    A[词级/短语级条件] --> B[整体知道这里有一串文字]
    B --> C[问题: 字符边界不清 相互污染]

    D[字符级条件与注意力] --> E[每个字符更独立地绑定区域]
    E --> F[结果: 拼写更稳定 可读性更高]</pre><hr>
<h3>第五阶段：也许模型不该负责“从头学拼写”，而该负责“把文字融合进场景”</h3>
<p>翻到比较新的工作时，我最感兴趣的一条思路，其实不是单纯“准确率又涨了多少”，而是问题定义开始变了。</p>
<p>比如 TextFlux 的意思就很明显：<br>它强调的是 <strong>OCR-free DiT model for high-fidelity multilingual scene text synthesis</strong>，同时把重点放在 glyph accuracy 和 scene integration 上。 <a href="https://arxiv.org/abs/2505.03329?utm_source=chatgpt.com">oai_citation:11‡arXiv</a></p>
<p>这背后有个挺大的范式变化：</p>
<ul>
<li>旧问题：怎么让模型自己从语义里学会 spelling</li>
<li>新问题：怎么把可靠的字符表示自然地融进图像场景</li>
</ul>
<p>我越来越觉得，后者可能才是更长期的方向。</p>
<p>因为如果你让一个通用图像模型同时承担两件事：</p>
<ol>
<li>生成复杂视觉世界  </li>
<li>还得像排版引擎一样精确输出字符</li>
</ol>
<p>那它天然就有点拧巴。<br>但如果你把 spelling 这件事尽量结构化、显式化，让模型把重心放在融合——也就是风格、材质、光照、透视、边缘过渡——这条路反而更合理。</p>
<p>这也是为什么我现在会更愿意把这条研究线理解成：</p>
<blockquote>
<p>不是“模型终于学会写字了”，<br>而是“系统终于不再把文字当普通纹理处理了”。</p>
</blockquote>
<hr>
<h2>把这些方案压成一句话，其实就是三步</h2>
<p>如果不展开细节，把这几年的路线浓缩一下，我觉得就是下面三步：</p>
<h3>第一步：把文字从语义提示升级成字形条件</h3>
<p>也就是从 prompt-only 走向 glyph-first。<br>这是 GlyphControl 和 AnyText 这类方法最早讲清楚的事。 <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:12‡arXiv</a></p>
<h3>第二步：把文字区域从整图里单独拎出来优化</h3>
<p>也就是别让全图损失继续淹没文字区域。<br>FLUX-Text 这种 regional text loss 的思路很典型。 <a href="https://arxiv.org/abs/2505.03329?utm_source=chatgpt.com">oai_citation:13‡arXiv</a></p>
<h3>第三步：把控制粒度从词级推进到字符级，再进一步推进到场景融合级</h3>
<p>TextPixs 代表字符级绑定这一步，后续一些工作则更强调 scene integration。 <a href="https://arxiv.org/abs/2507.06033?utm_source=chatgpt.com">oai_citation:14‡arXiv</a></p>
<pre class="mermaid">flowchart TD
    A[文字问题的主线解法] --> B[glyph-first]
    A --> C[region-first]
    A --> D[character-first]
    A --> E[integration-first]

    B --> B1[不只告诉模型写什么 还告诉它长什么样]
    C --> C1[文字区域单独加权优化]
    D --> D1[字符级注意力与约束]
    E --> E1[重点解决和背景如何自然融合]</pre><hr>
<h2>我自己的感受：这可能是图像生成从“会画图”走向“能用”的一个拐点</h2>
<p>我这次翻论文，最大的感受不是“原来某个模块这么 clever”，而是：</p>
<p><strong>文字问题其实特别像一个分水岭。</strong></p>
<p>过去很多视觉生成模型，主要解决的是“生成一张看起来不错的图”。<br>但只要场景变成海报、包装、UI、招牌、广告、知识卡片，评价标准就会立刻变化：</p>
<ul>
<li>画面再美，字错了就没法用；</li>
<li>氛围再好，标题糊了就不能进生产流程；</li>
<li>文字编辑不稳定，就很难真正接入设计工作流。</li>
</ul>
<p>所以文字渲染这件事，表面上看像个细节，实际上逼着整个系统往更工程化、更结构化的方向走。<br>它迫使模型回答一个过去可以回避的问题：</p>
<blockquote>
<p>你到底是在生成“好看的视觉纹理”，<br>还是在生成“可被人类读懂和使用的信息”？</p>
</blockquote>
<p>而最近这些论文给出的共同答案大概是：</p>
<p><strong>如果你想生成的是后者，那就别再把文字当普通图像内容。</strong></p>
<hr>
<h2>参考论文</h2>
<p>[1] Yukang Yang et al., <strong>GlyphControl: Glyph Conditional Control for Visual Text Generation</strong>, NeurIPS 2023. 提出 glyph-conditional control，并构建 LAION-Glyph 与 OCR-based 评测。 <a href="https://arxiv.org/abs/2305.18259?utm_source=chatgpt.com">oai_citation:15‡arXiv</a></p>
<p>[2] Yuxiang Tuo et al., <strong>AnyText: Multilingual Visual Text Generation and Editing</strong>, 2023/2024. 提出 auxiliary latent module、text embedding module，以及 AnyWord-3M / AnyText-benchmark。 <a href="https://arxiv.org/abs/2311.03054?utm_source=chatgpt.com">oai_citation:16‡arXiv</a></p>
<p>[3] Rui Lan et al., <strong>FLUX-Text: A Simple and Advanced Diffusion Transformer Baseline for Scene Text Editing</strong>, 2025. 强调轻量 glyph/text embedding 与 text fidelity，并引入文字区域感知的优化思路。 <a href="https://arxiv.org/abs/2505.03329?utm_source=chatgpt.com">oai_citation:17‡arXiv</a></p>
<p>[4] <strong>TextPixs: Glyph-Conditioned Diffusion with Character-Aware Attention and OCR-in-the-Loop Feedback for Accurate Text Rendering</strong>, 2025. 强调 dual-stream、character-aware attention、OCR-in-the-loop，以及字符级准确性。 <a href="https://arxiv.org/abs/2507.06033?utm_source=chatgpt.com">oai_citation:18‡arXiv</a></p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/20260314#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233515</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>把经验写进仓库：关于 Skills 的一些想法</title>
    <link>https://liuyaowen.cn/posts/default/experience-skills-thoughts</link>
    <pubDate>Wed, 11 Mar 2026 09:44:31 GMT</pubDate>
    <description>把经验写进仓库

最近读 OpenAI 那篇讲 Skills 和 Agents SDK 的文章，脑子</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/experience-skills-thoughts'>https://liuyaowen.cn/posts/default/experience-skills-thoughts</a></blockquote>
          <h1>把经验写进仓库</h1>
<p>最近读 OpenAI 那篇讲 Skills 和 Agents SDK 的文章，脑子里一直在转一个很小的问题。</p>
<p>我们到底是在使用 AI，还是在把自己的工作方式，一点点整理成 AI 也能接住的东西。</p>
<p>以前总觉得，所谓“AI 提效”，核心是模型越来越强。它会写代码，会解释报错，会总结文档，也能把一段模糊的需求翻译成还算可用的实现。可真把它放进日常工程里，事情很快又会变得具体起来。</p>
<p>你会发现，问题常常不是它不够聪明，而是它不知道你们平时到底怎么做事。</p>
<p>同样是改完代码之后的“检查一下”，有人会顺手把 format、lint、test 全跑完，有人只会跑当前模块，有人记得补 typecheck，有人则默认 CI 会兜底。<br>同样是准备提 PR，有人会把改动背景、影响范围和验证方式写得很清楚，有人只留下一个简短标题，像往河里扔下一颗石头，剩下的涟漪全交给 reviewer 自己理解。</p>
<p>这些差别，看起来像是个人习惯。可如果往下想一层，它其实是一种没有被正式写下来的经验。</p>
<p>而 Skills 有意思的地方就在这里。</p>
<p>它不是让模型再多会一点东西，也不是再给 prompt 换一个更精致的名字。它更像是在说：如果一件事会一遍遍发生，如果它背后已经有了相对稳定的做法，那为什么不把它认真写下来，写进仓库，写成一种能被调用的能力。</p>
<p>这件事让我有点触动。因为它不是在追求一种更炫的智能感，反而是在做一件很朴素的事：把原本只存在于人脑子里的工作习惯，慢慢整理成系统的一部分。</p>
<h2>Skill 像什么</h2>
<p>如果只从表面看，skill 很容易被理解成“提示词模板”。</p>
<p>但我越来越觉得，它更像是一个很小的工作单元。<br>它知道自己该在什么时候出现，知道自己要完成什么，也知道哪些地方该交给模型判断，哪些地方该交给脚本执行。</p>
<p>OpenAI 的文章里提到，一个 skill 通常会有自己的说明文件，也可能带着脚本、参考资料，甚至一些辅助资源。换句话说，它并不是一句轻飘飘的“帮我做这个”，而是一套被稍微整理过的经验。</p>
<p>我很喜欢这种感觉。</p>
<p>因为很多时候，团队里的知识并不是没有，只是它们以一种很松散的形式存在着。它可能藏在某个资深工程师的习惯里，藏在某次 code review 的评论里，藏在一个只被提过一次的内部约定里。你问起来，大家都知道一点；真要写下来，又总觉得“这种事不是理所当然的吗”。</p>
<p>可一旦要交给 Agent，很多“理所当然”就突然变得不再理所当然了。</p>
<p>它不会天然知道，改了这几个目录意味着什么。<br>它不会天然知道，某一类改动是必须完整验证的，另一类改动却只需要最小检查。<br>它也不会知道，你们习惯怎样描述一次改动，怎样把上下文交代给下一个接手的人。</p>
<p>于是 skill 的意义开始变得清楚起来。<br>它不是替代经验，而是在保存经验。<br>不是制造新的流程，而是把原来那些靠默契维持的东西，慢慢落成可以复用的形状。</p>
<h2>真正难的，不是写“做什么”，而是写“什么时候做”</h2>
<p>我觉得 OpenAI 那篇文章里最值得反复想的一点，是它对 skill description 的强调。</p>
<p>一个 skill 是否真的有用，关键不是写得多完整，而是写得够不够准。<br>尤其是那个“什么时候该触发”的边界。</p>
<p>这件事听起来很细，可其实很像平时和人协作。<br>真正让一段协作顺畅起来的，从来不只是步骤本身，而是时机。</p>
<p>如果你只说“运行验证流程”，这当然没错，但几乎没什么帮助。<br>因为接下来最重要的问题都还没回答：</p>
<p>什么样的改动算需要验证？<br>只是改了文档呢？<br>只是重命名文件呢？<br>如果动到了测试或者构建链路，是不是就应该升级为完整检查？<br>这个动作是建议性的，还是必须性的？</p>
<p>当这些边界没被说清楚时，一个 skill 就很容易变得像一件摆设。它存在，但不稳定；偶尔会被调用，偶尔又会被忽略。最后的问题不在模型，而在于这个能力没有真正定义好自己出现的时刻。</p>
<p>我会觉得，写 skill 有点像在写一种很轻的制度。<br>制度不是靠语气强硬，而是靠边界清楚。<br>什么时候该发生，什么时候不该发生，什么属于例外，什么必须执行，这些东西一旦明确了，后面的流程反而会变简单。</p>
<h2>仓库里其实一直缺一种“写给 Agent 的文档”</h2>
<p>文章里还提到了 <code>AGENTS.md</code>。这个设定我也很喜欢。</p>
<p>它有一点像过去我们写给新同事的 onboarding 文档，只不过这次读者不是人，而是 Agent。</p>
<p>仓库有自己的脾气。<br>目录结构是怎么分的，哪里是核心路径，哪些约定看起来不显眼却不能碰，某些模块的测试为什么要这么跑，某些 API 的行为到底应该信源码还是信文档，这些都是仓库内部的语言。</p>
<p>平时这些东西靠人来理解，问题也不大。人会猜，会追问，会顺着上下文补全很多隐含信息。<br>但 Agent 不太一样。它当然会推理，可它最怕的是那些所有人都默认存在、却没有人认真写下来的东西。</p>
<p>所以 <code>AGENTS.md</code> 给我的感觉，不是又多了一份文档，而是终于出现了一种明确的载体，去承接那些“本来应该早就写清楚”的仓库共识。</p>
<p>如果说 <code>AGENTS.md</code> 更像总说明，那 skill 就更像一个个局部工作流。<br>前者回答“这里是一个什么样的地方”，后者回答“在这里，某件事该怎么做”。</p>
<p>再往下，还有 GitHub Actions 这种更确定的自动化兜底。<br>这样一层层看下来，会觉得这个结构其实很顺。</p>
<p>人负责形成经验。<br>文档负责表达经验。<br>skill 负责调用经验。<br>脚本和 CI 负责执行经验。</p>
<p>这和我过去想象的“AI 改变工程”很不一样。它没有特别戏剧化，反而很像软件工程一直以来最熟悉的路径：把不稳定的手工操作，慢慢整理成稳定的系统行为。</p>
<h2>模型不需要什么都做</h2>
<p>这篇文章里还有一个我很认同的判断：模型不应该负责一切。</p>
<p>这件事说出来很简单，但真正做的时候，太容易贪心了。<br>因为模型看起来什么都能处理，于是我们会不自觉地把理解、判断、执行、格式化输出，全部揉成一团交给它。</p>
<p>结果通常不会太好。</p>
<p>需要理解上下文、做比较、做总结、做判断的事，模型很擅长。<br>但需要按固定顺序执行命令、收集状态、输出可预期结果的事，本质上还是脚本更适合。</p>
<p>这个边界一旦想清楚，很多设计问题就会顺下来。</p>
<p>比如判断一个改动是不是行为变更，这种事要靠模型。它需要读 diff，理解意图，也理解周边上下文。<br>但“先跑哪些命令、失败了该怎么汇总、从哪里收集 git 状态和分支信息”，这些就该尽量交给脚本。</p>
<p>我一直觉得，好的系统不是把能力堆在一个点上，而是把职责分开。<br>模型负责那些本来就不确定的部分。<br>脚本负责那些应该尽量确定的部分。</p>
<p>Skill 之所以让我觉得它更接近“工程”，也在这里。它没有把模型想象成一个全能的主体，而是愿意承认：真正稳定的工作流，往往来自不同能力的合作，而不是单一能力的膨胀。</p>
<h2>说到底，Skills 在整理的是“隐性经验”</h2>
<p>我后来越想越觉得，Skills 最值得珍惜的地方，可能不是它能提升多少效率，而是它逼着团队去面对一件平时不太愿意面对的事：我们的很多工作，其实建立在隐性经验上。</p>
<p>这些经验平时并不显得稀缺。<br>因为总有人知道。<br>一个团队里，总会有一些人对仓库很熟，对流程很熟，对各种“虽然没写，但大家都这么做”的东西很熟。于是问题看起来总能被解决，流程看起来总能被跑通。</p>
<p>可一旦团队变大，项目变复杂，或者引入 Agent，这些隐性的部分就会立刻暴露出来。</p>
<p>因为 AI 不会自动继承团队默契。<br>它也不会天然理解某些“本来不用说”的规则。</p>
<p>于是很多以前能被熟人网络悄悄消化掉的问题，现在都必须被明说了：</p>
<p>什么是必须做的？<br>什么只是建议？<br>哪些步骤必须自动化？<br>哪些判断仍然需要人来做？<br>哪些知识应该写进仓库，而不是留在聊天记录里？</p>
<p>这让我觉得，Skills 表面上是在帮助 Agent，实际上也在帮助团队重新认识自己。<br>你们到底是怎样工作的。<br>哪些经验是可迁移的。<br>哪些流程值得固化。<br>哪些依赖于个人记忆的地方，早就该被替换掉了。</p>
<p>从这个角度看，它不是一个单纯的新功能，而像一次很安静的整理。</p>
<h2>我会从什么地方开始</h2>
<p>如果真要在一个仓库里慢慢把 skill 建起来，我大概不会一上来就做一个很大的系统。</p>
<p>我更愿意从那些已经足够重复、足够明确、而且失败成本又不低的事情开始。</p>
<p>比如验证流程。<br>这几乎是最自然的一类。改动之后该跑什么、顺序是什么、哪些情况需要附加检查、失败时怎么把结果说清楚，这些都很适合被整理成 skill。它高频、重复，而且一旦依赖记忆，就特别容易漏。</p>
<p>再比如 PR 的整理工作。<br>标题怎么起，改动怎么概括，影响范围怎么交代，验证方式怎么写得让 reviewer 不费力。这种事情本身不算难，但很消耗注意力。一个好的 draft 往往就能省掉很多来回解释。</p>
<p>还有文档核对。<br>OpenAI 在文章里提到，涉及平台或 API 行为时，他们会让 Agent 去查当前文档，而不是凭记忆回答。这个原则我很喜欢。因为变化快的东西最怕“我记得好像是这样”。把“先查文档再回答”变成一种默认动作，本质上是在给系统加一个很必要的约束。</p>
<p>再往后，可能是发版前检查。<br>这种流程不一定每天发生，但一旦出错，往往会让人很后悔。版本号、changelog、示例是否可运行、release note 草稿、breaking changes 的确认，这些都很适合被慢慢沉淀下来。</p>
<p>它们有一个共同点：都不是创造性的工作，而是重复性的工作。<br>也正因为如此，它们才更值得被写进仓库。</p>
<h2>我为什么会对这件事有一点点乐观</h2>
<p>这几年关于 AI 的讨论很多，热闹也很多。<br>可越往后，我反而越容易被一些安静的东西打动。不是模型又刷新了哪个 benchmark，不是 Agent 又完成了多复杂的任务，而是像 Skills 这种，看起来没有那么耀眼，却很接近真实工作现场的设计。</p>
<p>它让我觉得，AI 真正进入工程，不一定是靠一次 dramatic 的替代，而更可能是靠这种缓慢的渗透：先接住一个小流程，再接住一种工作习惯，再把一种原本只存在于经验里的做法，慢慢变成仓库的一部分。</p>
<p>这件事的迷人之处在于，它不是在制造新的神话，而是在保存那些原本就有价值的东西。</p>
<p>我们一直说，软件工程是在把手工经验系统化。<br>某种程度上，Skills 只是把这句话继续往前推了一步。</p>
<p>以前我们把经验写成文档，写成脚本，写成 CI。<br>现在，我们开始尝试把经验写成 Agent 也能理解和使用的能力。</p>
<p>我挺喜欢这种变化。</p>
<p>因为它让我第一次觉得，Agent 不是突然闯进工程体系里的一个外来者。<br>它更像一个新的参与者。<br>而一个新的参与者，最重要的不是“它能做多少事”，而是“我们愿不愿意认真把自己的工作方式告诉它”。</p>
<p>说到底，skill 不是在教模型怎么工作。<br>它是在逼我们先想清楚，自己到底是怎么工作的。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/experience-skills-thoughts#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233514</guid>
  <category>post</category>
<category>技术</category>
 </item>
  
</channel>
</rss>