<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>Sun, 26 Apr 2026 14:03:14 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>一键配置高效终端：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>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<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'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69cf5354c91182cb360285ad</guid>
  <category>posts</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>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<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'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69ce2d5ac91182cb360244e2</guid>
  <category>posts</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>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<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'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69b979860c469e94b7317f9d</guid>
  <category>posts</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>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<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'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69b57c050c469e94b73107cd</guid>
  <category>posts</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>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<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'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69b1397f0c469e94b7308bb0</guid>
  <category>posts</category>
<category>技术</category>
 </item>
  <item>
    <title>给 OpenClaw 装上眼睛：ARM64 服务器无头浏览器实战指南</title>
    <link>https://liuyaowen.cn/posts/default/openclaw-arm64-1772639270</link>
    <pubDate>Wed, 04 Mar 2026 15:47:51 GMT</pubDate>
    <description>很多同学选择在服务器上部署 OpenClaw 作为个人 AI 助手，但默认情况下 OpenClaw </description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://liuyaowen.cn/posts/default/openclaw-arm64-1772639270'>https://liuyaowen.cn/posts/default/openclaw-arm64-1772639270</a></blockquote>
      <p>很多同学选择在服务器上部署 OpenClaw 作为个人 AI 助手，但默认情况下 OpenClaw 无法访问网页。这对于一个需要&quot;上网冲浪&quot;的 AI 助手来说无疑是最大的限制。</p>
<p>好消息是，通过配置无头浏览器（Headless Browser），我们可以让 OpenClaw 具备网页访问、截图、自动化操作等能力。本文将详细介绍在 ARM64 架构服务器上的完整配置过程。</p>
<hr>
<h2>为什么需要浏览器</h2>
<p>OpenClaw 本身是一个 AI 助手框架，要让它真正&quot;智能&quot;地帮你做事，浏览器能力必不可少：</p>
<ul>
<li><strong>实时信息获取</strong>：天气、新闻、股票等</li>
<li><strong>网页自动化</strong>：自动填表、点击操作、定时签到</li>
<li><strong>内容截图</strong>：把网页截图发给你</li>
<li><strong>爬虫能力</strong>：抓取特定网站内容</li>
</ul>
<hr>
<h2>ARM64 的困境</h2>
<p>市面上的 Chrome 浏览器只有 amd64 架构版本，而很多同学使用 Apple Silicon Mac 或 ARM 服务器（如腾讯云 Lighthouse）。这导致常规的 Chrome 安装方法行不通。</p>
<p>解决方案有两个：</p>
<ol>
<li><strong>使用 Snap Chromium</strong>：Ubuntu 自带，但配置麻烦</li>
<li><strong>使用 Playwright Chromium</strong>：跨平台，兼容 ARM64</li>
</ol>
<p>本文选择方案 2，因为它更可控。</p>
<hr>
<h2>技术栈</h2>
<table>
<thead>
<tr>
<th>组件</th>
<th>作用</th>
<th>备注</th>
</tr>
</thead>
<tbody><tr>
<td>Playwright</td>
<td>浏览器自动化框架</td>
<td>提供 Chromium 二进制</td>
</tr>
<tr>
<td>Chromium</td>
<td>无头浏览器</td>
<td>ARM64 兼容版本</td>
</tr>
<tr>
<td>CDP (Chrome DevTools Protocol)</td>
<td>浏览器调试协议</td>
<td>OpenClaw 通过它控制浏览器</td>
</tr>
</tbody></table>
<hr>
<h2>完整配置步骤</h2>
<h3>1. 安装 Playwright Chromium</h3>
<pre><code class="language-bash">npx playwright install chromium</code></pre><p>Playwright 会自动下载适配当前架构的 Chromium。</p>
<h3>2. 安装中文字体</h3>
<p>无头浏览器截图中文字体需要单独安装：</p>
<pre><code class="language-bash">sudo apt-get install -y fonts-wqy-microhei fonts-wqy-zenhei
fc-cache -fv</code></pre><h3>3. 启动浏览器</h3>
<p>我们需要让 Chromium 以 CDP 模式启动：</p>
<pre><code class="language-bash">nohup ~/.cache/ms-playwright/chromium-1208/chrome-linux/chrome \
  --headless=new \
  --no-sandbox \
  --disable-gpu \
  --remote-debugging-port=18800 \
  --disable-dev-shm-usage \
  --disable-software-rasterizer &gt; /tmp/chrome.log 2&gt;&1 &

# 验证启动成功
curl -s http://127.0.0.1:18800/json/version</code></pre><h3>4. 配置 OpenClaw</h3>
<p>编辑 <code>~/.openclaw/openclaw.json</code>，添加/修改 browser 部分：</p>
<pre><code class="language-json">{
  "browser": {
    "enabled": true,
    "attachOnly": false,
    "headless": true,
    "noSandbox": true,
    "profiles": {
      "openclaw": {
        "cdpPort": 18800,
        "color": "0000FF"
      }
    }
  }
}</code></pre><h3>5. 验证配置</h3>
<pre><code class="language-bash">openclaw browser status
openclaw browser screenshot</code></pre><hr>
<h2>进阶使用</h2>
<h3>自动启动脚本</h3>
<p>为了保证服务器重启后浏览器能自动启动，建议添加 systemd 服务：</p>
<pre><code class="language-ini">[Unit]
Description=OpenClaw Headless Browser
After=network.target

[Service]
Type=simple
User=liuyaowen
ExecStart=/home/liuyaowen/.cache/ms-playwright/chromium-1208/chrome-linux/chrome --headless=new --no-sandbox --disable-gpu --remote-debugging-port=18800 --disable-dev-shm-usage
Restart=on-failure

[Install]
WantedBy=multi-user.target</code></pre><h3>浏览器 Profile 持久化</h3>
<p>如果需要保持登录状态，可以指定用户数据目录：</p>
<pre><code class="language-bash">--user-data-dir=/path/to/profile</code></pre><p>这样浏览器会记住 Cookie 和会话，登录一次即可。</p>
<hr>
<h2>常见问题</h2>
<h3>字体仍然乱码</h3>
<p>检查字体是否正确加载：</p>
<pre><code class="language-bash">fc-list :lang=zh</code></pre><p>如果为空，可能需要手动下载字体到 Chrome 可访问的目录。</p>
<h3>CDP 连接失败</h3>
<ol>
<li>检查端口是否被占用：<code>lsof -i:18800</code></li>
<li>查看浏览器日志：<code>cat /tmp/chrome.log</code></li>
<li>尝试重启浏览器</li>
</ol>
<h3>截图空白</h3>
<p>可能是页面还没加载完成，增加等待时间：</p>
<pre><code class="language-javascript">await page.waitForLoadState("networkidle");</code></pre><hr>
<h2>效果展示</h2>
<p>配置完成后，你可以：</p>
<ol>
<li><strong>让 AI 帮你查天气</strong>：直接截图当前天气发给你</li>
<li><strong>自动签到</strong>：每天定时打开网页点击签到按钮</li>
<li><strong>内容监控</strong>：监控某个网页变化后通知你</li>
<li><strong>生成报表</strong>：自动打开管理后台截图生成日报</li>
</ol>
<hr>
<h2>总结</h2>
<p>本文详细介绍了在 ARM64 服务器上为 OpenClaw 配置无头浏览器的完整过程。虽然过程比直接安装 Chrome 复杂一些，但通过 Playwright + CDP 的组合，我们最终实现了：</p>
<ul>
<li>✅ ARM64 兼容</li>
<li>✅ 中文字体支持</li>
<li>✅ OpenClaw 集成</li>
<li>✅ 自动化能力</li>
</ul>
<p>如果你也有在服务器上运行 AI 助手的需求，不妨试试这个方案。</p>
<hr>
<h2>参考资源</h2>
<ul>
<li><a href="https://playwright.dev/">Playwright 官方文档</a></li>
<li><a href="https://docs.openclaw.ai/">OpenClaw 文档</a></li>
<li><a href="https://github.com/mx-space/core">mx-space 项目</a></li>
</ul>

      <p style='text-align: right'>
      <a href='https://liuyaowen.cn/posts/default/openclaw-arm64-1772639270#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69a8542771c68afbd9286bf3</guid>
  <category>posts</category>
<category>技术</category>
 </item>
  <item>
    <title>xMemory：超越RAG的智能体记忆</title>
    <link>https://liuyaowen.cn/posts/codenotes/xmemory-intelligent-agent-memory-beyond-rag</link>
    <pubDate>Fri, 27 Feb 2026 12:19:50 GMT</pubDate>
    <description>ICML 2025 最佳论文力作：xMemory 能否彻底解决 RAG 的记忆困境？

在大模型 A</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://liuyaowen.cn/posts/codenotes/xmemory-intelligent-agent-memory-beyond-rag'>https://liuyaowen.cn/posts/codenotes/xmemory-intelligent-agent-memory-beyond-rag</a></blockquote>
      <p>ICML 2025 最佳论文力作：xMemory 能否彻底解决 RAG 的记忆困境？</p>
<p>在大模型 Agent 蓬勃发展的今天，如何让 AI 记住跨越数十次会话的对话历史，已成为决定 Agent 实用性的关键因素。当我们与一个陪伴数月的 AI 助手交流时，我们希望它记得我们之前讨论过的项目偏好、了解我们的工作习惯、甚至记住某次聊天中提到的关键细节。然而，标准的检索增强生成（RAG）方法在面对这种「智能体记忆」场景时，却暴露出惊人的局限性。</p>
<h2>被忽视的根本问题：RAG 与智能体记忆的「基因不兼容」</h2>
<p>在深入 xMemory 之前，我们有必要先理解这场变革的核心背景。传统 RAG 的设计初衷是什么？它是为了在海量的互联网文档、企业的知识库、或者数百篇研究论文中进行检索而生的。在这些场景中，被检索的文本片段来自不同来源，彼此之间往往主题各异、视角多元。正因为如此，传统的「top-k 相似度检索」能够有效地从大量异质候选项中筛选出与当前查询最相关的不同信息。</p>
<p>但智能体记忆的场景与上述情况截然不同。当一个用户与 Agent 进行跨越数周甚至数月的多轮对话时，存储的记忆呈现出一种独特的结构：高度相关、高度冗余、并且在时间维度上紧密相连。举例来说，如果用户在一个月内多次讨论同一个项目，那么关于这个项目的所有对话片段在语义空间中会形成一团密集的「云」—— 它们彼此之间的相似度极高，但与用户当前查询相关的核心信息可能只占这团密云中的几个点。</p>
<p>这带来一个尴尬的局面：当使用标准的 top-5 或 top-10 检索时，返回的结果往往是同一个主题下的近重复片段。换句话说，RAG 系统辛辛苦苦找到的「最相关」上下文，其实只是在反复讲述同一件事，而真正需要的前后时间关联的证据链，反而被算法当作冗余内容「优化」掉了。</p>
<p>xMemory 论文的核心洞察正是这一点：标准 RAG 假设的是「异构文本库」，而智能体记忆本质上是「同构记忆流」。这种根本性的不匹配，不是简单的调参所能解决的。</p>
<h2>xMemory 的核心思路：解耦与聚合</h2>
<p>面对这一挑战，xMemory 提出了一个优雅而深刻的解决方案——「解耦到聚合」（Decoupling-then-Aggregation）。这个理念的核心思想是：与其在原始对话片段的层面挣扎，不如先对记忆进行结构化组织，然后再反转这个结构来驱动检索。</p>
<p>具体而言，xMemory 构建了一个四级层次记忆结构：</p>
<ul>
<li><strong>原始消息（Messages）</strong>：用户与 Agent 之间的实际对话轮次</li>
<li><strong>会话片段（Episodes）</strong>：将连续的多轮对话压缩为概括性的摘要，捕捉一个完整的话题单元</li>
<li><strong>语义单元（Semantics）</strong>：从会话片段中提取的可重用的长期事实，比如用户的姓名、工作单位、偏好习惯等</li>
<li><strong>主题（Themes）</strong>：将相关的语义单元组织在一起，形成更高层次的概念聚合</li>
</ul>
<p>这个层次结构的妙处在于，它将原本混杂在时间流中的信息进行了「解耦」——语义单元被单独提取出来，不再与原始对话轮次绑定；同时通过主题层的「聚合」，建立了语义单元之间的高层关联。这就像是把一滩浑浊的河水先进行沉淀和分层，然后再按需取用。</p>
<h2>两阶段自适应检索：代表选择与不确定性感知</h2>
<p>仅仅构建出层次结构还不够，关键是如何在这个结构上进行高效的检索。xMemory 采用了两阶段的自适应检索策略，正是我认为这篇论文最精彩的部分。</p>
<h3>第一阶段：kNN 图上的查询感知代表选择</h3>
<p>在主题层和语义单元层，xMemory 维护了一个 k 最近邻（kNN）图结构。当用户提出一个查询时，系统不是直接去原始对话中找最相似的片段，而是先在主题层进行「代表选择」——从多个相关主题中挑选出最能覆盖不同知识方向的代表节点。</p>
<p>这里的关键创新在于一个权衡公式：</p>
<pre><code class="language-">i* = argmax [α × 覆盖增益 + (1-α) × 查询相关性]</code></pre><p>系统会平衡两个目标：一方面要选择与当前查询语义相关的内容，另一方面要确保选中的内容能够覆盖记忆中的不同知识面，避免总是扎堆在某个密集区域。这个过程是迭代的——每选择一个节点，就更新其邻居的覆盖状态，直到达到某个覆盖率阈值。</p>
<h3>第二阶段：不确定性自适应的证据包含</h3>
<p>选中了相关的语义单元之后，下一步是决定要包含哪些具体的会话片段来作为「证据」。xMemory 的做法非常巧妙：它不是一股脑地把所有相关片段都塞进上下文窗口，而是基于「不确定性减少」的原则来进行筛选。</p>
<p>具体实现方式是：对于每个候选的会话片段，系统会评估它是否能减少语言模型对答案的预测不确定性。只有当额外包含某个片段能够显著降低答案的不确定性时，这个片段才会被纳入最终的上下文。这种方法天然地实现了「按需获取」的效果——与答案直接相关的细节会被保留，而冗余的近重复内容会被过滤掉。</p>
<h2>实验结果：效率与质量的双赢</h2>
<p>xMemory 在两个基准数据集上进行了充分验证：LoCoMo（多会话对话数据集，包含平均约 300 轮对话）和 PerLTQA（个人长期记忆问答）。</p>
<p>实验结果令人印象深刻：</p>
<ul>
<li>在 Qwen3-8B 模型上，xMemory 将平均 BLEU 从 28.51 提升到 34.48，F1 从 40.45 提升到 43.98</li>
<li>在 GPT-5 nano 上，同样取得了从 36.65 到 38.71（BLEU）和 48.17 到 50.00（F1）的提升</li>
<li>最令人惊叹的是 token 使用效率：在 Qwen3-8B 上，token 消耗从 9103 降至 4711，减少了近一半！</li>
</ul>
<p>这意味着 xMemory 不仅提高了答案质量，还同时降低了成本。对于需要长期运行、成本敏感的 Agent 应用来说，这无疑是一个极具吸引力的特性。</p>
<h2>更深层的启示</h2>
<p>读罢这篇论文，我不禁思考它对整个 Agent 领域的更深层启示。</p>
<p>首先，它揭示了一个重要的设计原则：「记忆的组织方式决定了检索的效率」。当我们设计 Agent 的记忆系统时，不应该仅仅关注如何存储和索引，更应该思考如何将知识「解耦」——把长期有效的通用事实与短期有效的对话上下文分离，把核心概念与细枝末节分离。这种分离本身就是一种智能。</p>
<p>其次，xMemory 的「两阶段检索」也暗示了一个更广泛的设计模式：用粗粒度的代表选择来控制搜索空间，然后再用精细的不确定性评估来筛选内容。这种「先粗后精」的策略，在许多需要高效利用有限资源的场景中都具有普适价值。</p>
<p>最后，这篇论文也提醒我们重新审视 RAG 的边界。RAG 是一个强大的范式，但它并非万能。当应用场景的特性与 RAG 的原始假设不匹配时，我们需要勇敢地进行定制化甚至重构，而不是削足适履地硬套。</p>
<h2>结语</h2>
<p>ICML 2025 的这篇论文，为智能体记忆这个前沿领域注入了一剂强心针。xMemory 用「解耦-聚合」的层次化思路和「代表选择-不确定性感知」的两阶段检索，成功地化解了标准 RAG 在同构记忆流场景下的困境。更难能可贵的是，它在提升答案质量的同时，还显著降低了 token 成本——这对于任何关注实际部署的工程师来说，都是无法忽视的诱惑。</p>
<p>如果你正在构建需要长期记忆的 AI Agent，或者对检索增强技术有更深的兴趣，这篇论文绝对值得一读。它不仅提供了一套可落地的技术方案，更重要的是，它展示了一种思考问题的方式——当你发现现有的方法论与问题本质不匹配时，不妨回到问题的源头，重新设计整个系统。</p>

      <p style='text-align: right'>
      <a href='https://liuyaowen.cn/posts/codenotes/xmemory-intelligent-agent-memory-beyond-rag#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69a18be6685a5f2c75fab0e1</guid>
  <category>posts</category>
<category>笔记</category>
 </item>
  <item>
    <title>认知红利：工作前几年如何真正拉开差距</title>
    <link>https://liuyaowen.cn/posts/default/20260123</link>
    <pubDate>Mon, 23 Feb 2026 07:29:57 GMT</pubDate>
    <description>在职场早期，很多人习惯用“多加班、多做项目、多报培训”来证明努力。但这些本质上都属于线性努力——当你</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://liuyaowen.cn/posts/default/20260123'>https://liuyaowen.cn/posts/default/20260123</a></blockquote>
      <p>在职场早期，很多人习惯用“多加班、多做项目、多报培训”来证明努力。但这些本质上都属于线性努力——当你与他人处在同一维度竞争时，结果只会出现有限差距。<br>真正能够拉开差距的，往往是认知升级带来的非线性成长。</p>
<p>结合实践经验，可以将关键认知总结为四个方面。</p>
<hr>
<h2>一、决策质量：成长的放大器</h2>
<p>职场中的关键节点——是否跳槽、是否转行、选择城市、选择公司、是否创业——本质上都是决策问题。<br>一旦决策正确，其增益往往远大于日常努力。</p>
<h3>1.1 要什么不重要，不要什么更重要</h3>
<p>很多人误以为“选择越多越好”，希望主业、副业、投资同时推进。但人的注意力与心力是有限资源，分散意味着难以形成突破。<br>在上升期，更有效的策略是：阶段性聚焦，一个阶段只解决一个核心目标。</p>
<h3>1.2 少做决策，做对决策</h3>
<p>决策并非越多越好。频繁决策往往伴随高错误率，并容易陷入连锁反应。例如跳槽失误后急于离开，容易在焦虑中再次做出错误选择，形成循环。<br>因此，更优策略是：把自己放在稳定环境中，用时间换取更高质量的关键决策。</p>
<h3>1.3 决策慢，是为了方向正确</h3>
<p>重大决策前需要刻意设置“冷却期”，系统收集信息并推演后果。<br>典型方法包括：</p>
<ul>
<li>向真实经历者咨询一手经验  </li>
<li>评估成功概率与失败成本  </li>
<li>判断新选择是否真正解决当前问题</li>
</ul>
<p>方向正确时，慢反而是最快的路径；方向错误时，速度只会放大损失。</p>
<hr>
<h2>二、高目标与高底线：避免“及格主义陷阱”</h2>
<p>脱离考试体系后，绝大多数人的工作标准完全由自我设定，容易滑向“完成任务即可”的交差逻辑。<br>这种及格主义会逐渐降低个人底线，最终形成能力天花板。</p>
<h3>2.1 从“交付任务”到“理解目标”</h3>
<p>真正的高标准工作方式是：</p>
<ul>
<li>理解任务背后的业务目标  </li>
<li>主动思考更优方案  </li>
<li>用结果价值而非动作完成度衡量自己</li>
</ul>
<h3>2.2 用标杆替代自我感动</h3>
<p>一个有效策略是：为自己设置行业标杆。<br>持续对齐行业优秀个体的标准，可以显著抬高自己的质量基线。当接近或超越当前标杆时，再引入新的标杆，形成阶梯式成长。</p>
<hr>
<h2>三、风险偏好：差距往往诞生于此</h2>
<p>在同等起点下，不同发展结果往往来自风险选择差异。<br>多数人倾向保守，追求确定性；但竞争优势往往隐藏在他人不敢投入的机会区间。</p>
<h3>3.1 风险的本质是机会筛选器</h3>
<p>组织或大公司通常不会重仓高不确定性但潜在高回报的机会，这为个人提供了竞争空间。<br>适度承担风险，本质是在更低竞争密度的赛道中获取成长。</p>
<h3>3.2 失败并非负收益</h3>
<p>对具备复盘能力的人而言，失败具有长期正向价值：</p>
<ul>
<li>提供认知样本  </li>
<li>修正决策模型  </li>
<li>提升风险评估能力</li>
</ul>
<p>人生本身具备较强纠错能力，一次失败通常难以造成不可逆后果。</p>
<hr>
<h2>四、主体性：从受害者叙事到主角叙事</h2>
<p>主体性决定了一个人如何解释世界，也决定了其行动边界。</p>
<h3>4.1 主体性弱的典型表现</h3>
<ul>
<li>将结果归因于环境、关系或运气  </li>
<li>对外部评价高度依赖  </li>
<li>容易陷入情绪内耗与自我否定</li>
</ul>
<p>这种状态会削弱行动力，并持续降低成长预期。</p>
<h3>4.2 主体性强的核心特征</h3>
<ul>
<li>建立自我评价体系  </li>
<li>将一切经历视为学习样本  </li>
<li>对外部评价保持参考而非依赖</li>
</ul>
<p>换句话说，是以“主角心态”理解世界：发生的事情都是素材，而非束缚。</p>
<hr>
<h2>认知升级，才是早期最大的杠杆</h2>
<p>工作前几年真正拉开差距的，并非投入时长，而是：</p>
<ul>
<li>用高质量决策放大努力  </li>
<li>用高目标抬升能力下限  </li>
<li>用风险选择获取非对称机会  </li>
<li>用主体性构建稳定内核</li>
</ul>
<p>努力决定下限，认知决定上限。当认知结构发生变化时，同样的努力会产生完全不同的结果。</p>

      <p style='text-align: right'>
      <a href='https://liuyaowen.cn/posts/default/20260123#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">699c01f5965c9595cc1fa6d4</guid>
  <category>posts</category>
<category>技术</category>
 </item>
  <item>
    <title>AT 分布式事务使用详解</title>
    <link>https://liuyaowen.cn/posts/default/20251217</link>
    <pubDate>Wed, 17 Dec 2025 12:30:08 GMT</pubDate>
    <description>一、AT 模式概述

AT (Automatic Transaction) 模式是 Seata 中最</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://liuyaowen.cn/posts/default/20251217'>https://liuyaowen.cn/posts/default/20251217</a></blockquote>
      <h2>一、AT 模式概述</h2>
<p>AT (Automatic Transaction) 模式是 Seata 中最常用的分布式事务模式，它通过自动生成反向 SQL 来实现事务的回滚，对业务代码侵入性极小。</p>
<h2>二、核心配置详解</h2>
<h3>1. 全局配置 (registry.conf)</h3>
<pre><code class="language-properties"># 注册中心配置
registry {
  type = "nacos"  # 支持 nacos、eureka、redis、zk、consul、etcd3、sofa
  
  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "default"
    username = "nacos"
    password = "nacos"
  }
}

# 配置中心
config {
  type = "nacos"
  
  nacos {
    serverAddr = "127.0.0.1:8848"
    namespace = ""
    group = "SEATA_GROUP"
    username = "nacos"
    password = "nacos"
    dataId = "seata-server.properties"
  }
}</code></pre><h3>2. 客户端配置 (application.yml)</h3>
<pre><code class="language-yaml">seata:
  enabled: true
  application-id: order-service  # 应用唯一标识
  tx-service-group: my_test_tx_group  # 事务组名称
  
  # 自动数据源代理
  enable-auto-data-source-proxy: true
  data-source-proxy-mode: AT  # AT 模式
  
  # 客户端配置
  config:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: ""
      data-id: seata-client.properties
  
  # 注册中心配置
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: ""
      cluster: default
  
  # 客户端详细配置
  client:
    rm:
      # 异步提交缓存队列长度
      async-commit-buffer-limit: 10000
      # 一阶段结果上报 TC 重试次数
      report-retry-count: 5
      # 自动刷新缓存中的表结构
      table-meta-check-enable: true
      # 分支事务与其它全局回滚事务冲突时锁策略
      report-success-enable: false
      # 是否上报一阶段成功
      saga-branch-register-enable: false
      # saga json parser
      saga-json-parser: fastjson
      # 一阶段全局提交结果上报 TC 重试次数
      saga-retry-persist-mode-update: false
      # 默认false，ture会提升性能
      saga-compensate-persist-mode-update: false
      # TCC 资源自动清理时间（小时）
      tcc-action-interceptor-order: -2147482648
      
    tm:
      # 一阶段全局提交结果上报 TC 重试次数
      commit-retry-count: 5
      # 一阶段全局回滚结果上报 TC 重试次数
      rollback-retry-count: 5
      # 默认全局事务超时时间（毫秒）
      default-global-transaction-timeout: 60000
      # 降级开关，默认 false
      degrade-check: false
      # 服务自检周期（毫秒）
      degrade-check-period: 2000
      # 允许降级检查的最小业务并发数
      degrade-check-allow-times: 10
      # 自检失败后开启降级的持续时间（毫秒）
      interceptor-order: -2147482648
      
    undo:
      # 是否开启二阶段回滚镜像校验
      data-validation: true
      # 二阶段回滚镜像校验失败的处理方式
      log-serialization: jackson
      # undo 序列化方式：jackson、fastjson、kryo
      log-table: undo_log
      # 自定义 undo 表名
      only-care-update-columns: true
      # 是否只生成被更新字段的镜像
      compress:
        enable: true
        # 是否压缩 undo_log
        type: zip
        # 压缩类型
        threshold: 64k
        # 压缩阈值
    
    # 负载均衡配置
    load-balance:
      type: RandomLoadBalance  # 负载均衡类型
      virtual-nodes: 10  # 虚拟节点数</code></pre><h3>3. 服务端配置 (Seata Server)</h3>
<pre><code class="language-properties"># 存储模式
store.mode=db  # file、db、redis

# 数据库存储配置
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useSSL=false
store.db.user=root
store.db.password=root
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000

# 事务、日志存储配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000</code></pre><h2>三、使用示例</h2>
<h3>1. 数据库准备</h3>
<p>每个业务库都需要创建 undo_log 表：</p>
<pre><code class="language-sql">CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;</code></pre><h3>2. 业务代码</h3>
<pre><code class="language-java">@Service
public class OrderServiceImpl implements OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private AccountService accountService;
    
    @Autowired
    private StorageService storageService;
    
    /**
     * 全局事务发起者，使用 @GlobalTransactional 注解
     */
    @Override
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    public void createOrder(Order order) {
        // 1. 创建订单
        orderMapper.insert(order);
        
        // 2. 扣减库存（远程调用）
        storageService.deduct(order.getProductId(), order.getCount());
        
        // 3. 扣减账户余额（远程调用）
        accountService.debit(order.getUserId(), order.getMoney());
        
        // 4. 更新订单状态
        order.setStatus(1);
        orderMapper.update(order);
    }
}

// 库存服务
@Service
public class StorageServiceImpl implements StorageService {
    
    @Autowired
    private StorageMapper storageMapper;
    
    @Override
    public void deduct(Long productId, Integer count) {
        // 直接执行业务逻辑，无需额外事务注解
        storageMapper.deduct(productId, count);
    }
}</code></pre><h2>四、底层实现原理</h2>
<h3>1. 核心组件架构</h3>
<pre><code class="language-">TC (Transaction Coordinator) - 事务协调器
├── 全局事务管理
├── 分支事务管理
└── 全局锁管理

TM (Transaction Manager) - 事务管理器
├── 全局事务开启
├── 全局事务提交
└── 全局事务回滚

RM (Resource Manager) - 资源管理器
├── 分支事务注册
├── 分支事务上报
└── 分支事务提交/回滚</code></pre><h3>2. AT 模式执行流程</h3>
<h4>第一阶段（执行业务 SQL）</h4>
<pre><code class="language-java">// DataSourceProxy 代理数据源
public class DataSourceProxy extends AbstractDataSourceProxy {
    
    @Override
    public ConnectionProxy getConnection() throws SQLException {
        Connection targetConnection = targetDataSource.getConnection();
        return new ConnectionProxy(this, targetConnection);
    }
}

// ConnectionProxy 核心逻辑
public class ConnectionProxy extends AbstractConnectionProxy {
    
    @Override
    public void commit() throws SQLException {
        try {
            // 注册分支事务
            register();
        } catch (TransactionException e) {
            // 识别并处理异常
            recognizeLockKeyConflictException(e, context.buildLockKeys());
        }
        
        try {
            // 执行本地事务提交
            targetConnection.commit();
        } catch (Throwable ex) {
            // 上报事务执行失败
            report(false);
            throw ex;
        }
        
        // 上报事务执行成功
        report(true);
    }
}

// ExecuteTemplate 执行模板
public class ExecuteTemplate {
    
    public static &lt;T, S extends Statement&gt; T execute(
            SQLRecognizer sqlRecognizer,
            StatementProxy<s> statementProxy,
            StatementCallback&lt;T, S&gt; statementCallback,
            Object... args) throws SQLException {
        
        // 1. 前置镜像：查询修改前的数据
        TableRecords beforeImage = buildBeforeImage(statementProxy, sqlRecognizer);
        
        // 2. 执行业务 SQL
        T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
        
        // 3. 后置镜像：查询修改后的数据
        TableRecords afterImage = buildAfterImage(statementProxy, sqlRecognizer);
        
        // 4. 构造 undo_log
        prepareUndoLog(beforeImage, afterImage);
        
        return result;
    }
}</code></pre><h4>核心数据结构</h4>
<pre><code class="language-java">// 前置镜像和后置镜像数据结构
public class TableRecords {
    private TableMeta tableMeta;
    private List&lt;Row&gt; rows;
    
    // 行数据
    public static class Row {
        private List&lt;Field&gt; fields;
        
        public static class Field {
            private String name;        // 字段名
            private int keyType;        // 主键类型
            private Object value;       // 字段值
        }
    }
}

// Undo Log 结构
public class BranchUndoLog {
    private String xid;                    // 全局事务ID
    private long branchId;                 // 分支事务ID
    private List&lt;SQLUndoLog&gt; sqlUndoLogs;  // SQL回滚日志
    
    public static class SQLUndoLog {
        private String sqlType;            // INSERT/UPDATE/DELETE
        private String tableName;          // 表名
        private TableRecords beforeImage;  // 前置镜像
        private TableRecords afterImage;   // 后置镜像
    }
}</code></pre><h4>第二阶段（提交或回滚）</h4>
<p><strong>提交流程：</strong></p>
<pre><code class="language-java">public class AsyncWorker implements ResourceManagerInbound {
    
    /**
     * 异步提交分支事务
     */
    public BranchStatus branchCommit(String xid, long branchId, 
                                     String resourceId) {
        // AT 模式下，一阶段已经提交，二阶段只需删除 undo_log
        return asyncCommit(xid, branchId, resourceId);
    }
    
    private BranchStatus asyncCommit(String xid, long branchId, 
                                     String resourceId) {
        // 加入异步删除队列
        addToCommitQueue(xid, branchId, resourceId);
        return BranchStatus.PhaseTwo_Committed;
    }
    
    // 异步删除 undo_log
    private void deleteUndoLog(String xid, long branchId) {
        String sql = "DELETE FROM undo_log WHERE xid = ? AND branch_id = ?";
        executeUpdate(sql, xid, branchId);
    }
}</code></pre><p><strong>回滚流程：</strong></p>
<pre><code class="language-java">public class UndoLogManager {
    
    /**
     * 回滚分支事务
     */
    public void undo(DataSourceProxy dataSourceProxy, String xid, 
                     long branchId) throws SQLException {
        
        Connection conn = dataSourceProxy.getConnection();
        
        try {
            // 1. 查询 undo_log
            String selectSQL = "SELECT * FROM undo_log WHERE xid = ? " +
                             "AND branch_id = ? FOR UPDATE";
            BranchUndoLog branchUndoLog = selectUndoLog(conn, xid, branchId);
            
            if (branchUndoLog == null) {
                return;  // 已经回滚或提交
            }
            
            // 2. 数据校验
            if (!dataValidation(conn, branchUndoLog)) {
                throw new SQLException("Data validation failed");
            }
            
            // 3. 生成反向 SQL 并执行
            for (SQLUndoLog sqlUndoLog : branchUndoLog.getSqlUndoLogs()) {
                AbstractUndoExecutor undoExecutor = 
                    UndoExecutorFactory.getUndoExecutor(
                        dataSourceProxy.getDbType(), sqlUndoLog);
                undoExecutor.executeOn(conn);
            }
            
            // 4. 删除 undo_log
            String deleteSQL = "DELETE FROM undo_log WHERE xid = ? " +
                             "AND branch_id = ?";
            executeUpdate(conn, deleteSQL, xid, branchId);
            
            conn.commit();
            
        } catch (Exception e) {
            conn.rollback();
            throw e;
        }
    }
    
    /**
     * 数据校验：比对当前数据和后置镜像
     */
    private boolean dataValidation(Connection conn, 
                                   BranchUndoLog branchUndoLog) {
        for (SQLUndoLog sqlUndoLog : branchUndoLog.getSqlUndoLogs()) {
            TableRecords afterImage = sqlUndoLog.getAfterImage();
            TableRecords currentRecords = queryCurrentRecords(conn, afterImage);
            
            // 比对数据是否一致
            if (!afterImage.equals(currentRecords)) {
                return false;  // 数据被脏写
            }
        }
        return true;
    }
}</code></pre><h4>反向 SQL 生成逻辑</h4>
<pre><code class="language-java">// UPDATE 反向 SQL
public class MySQLUndoUpdateExecutor extends AbstractUndoExecutor {
    
    @Override
    protected String buildUndoSQL() {
        TableRecords beforeImage = sqlUndoLog.getBeforeImage();
        
        // 根据前置镜像生成 UPDATE 语句
        StringBuilder sql = new StringBuilder("UPDATE ");
        sql.append(sqlUndoLog.getTableName()).append(" SET ");
        
        // 设置字段值为前置镜像的值
        List&lt;Field&gt; fields = beforeImage.getRows().get(0).getFields();
        for (int i = 0; i &lt; fields.size(); i++) {
            if (i &gt; 0) sql.append(", ");
            sql.append(fields.get(i).getName()).append(" = ?");
        }
        
        // WHERE 条件（主键）
        sql.append(" WHERE ");
        appendWhereCondition(sql, beforeImage);
        
        return sql.toString();
    }
}

// DELETE 反向 SQL（生成 INSERT）
public class MySQLUndoDeleteExecutor extends AbstractUndoExecutor {
    
    @Override
    protected String buildUndoSQL() {
        TableRecords beforeImage = sqlUndoLog.getBeforeImage();
        
        // 根据前置镜像生成 INSERT 语句
        StringBuilder sql = new StringBuilder("INSERT INTO ");
        sql.append(sqlUndoLog.getTableName()).append(" (");
        
        // 字段列表
        List&lt;Field&gt; fields = beforeImage.getRows().get(0).getFields();
        for (int i = 0; i &lt; fields.size(); i++) {
            if (i &gt; 0) sql.append(", ");
            sql.append(fields.get(i).getName());
        }
        
        sql.append(") VALUES (");
        for (int i = 0; i &lt; fields.size(); i++) {
            if (i &gt; 0) sql.append(", ");
            sql.append("?");
        }
        sql.append(")");
        
        return sql.toString();
    }
}

// INSERT 反向 SQL（生成 DELETE）
public class MySQLUndoInsertExecutor extends AbstractUndoExecutor {
    
    @Override
    protected String buildUndoSQL() {
        TableRecords afterImage = sqlUndoLog.getAfterImage();
        
        // 根据后置镜像生成 DELETE 语句
        StringBuilder sql = new StringBuilder("DELETE FROM ");
        sql.append(sqlUndoLog.getTableName());
        sql.append(" WHERE ");
        
        appendWhereCondition(sql, afterImage);
        
        return sql.toString();
    }
}</code></pre><h3>3. 全局锁机制</h3>
<pre><code class="language-java">public class LockManagerImpl implements LockManager {
    
    /**
     * 获取全局锁
     */
    public boolean acquireLock(List&lt;RowLock&gt; rowLocks) {
        // 锁的粒度：表名 + 主键值
        // 例如：order_table:1,2,3
        String lockKey = buildLockKey(rowLocks);
        
        // 向 TC 申请全局锁
        boolean result = transactionCoordinator.acquireLock(
            xid, branchId, resourceId, lockKey);
        
        if (!result) {
            // 获取锁失败，进入重试逻辑
            return retryAcquireLock(rowLocks);
        }
        
        return true;
    }
    
    private String buildLockKey(List&lt;RowLock&gt; rowLocks) {
        // 格式：table1:pk1,pk2;table2:pk3,pk4
        Map&lt;String, Set&lt;String&gt;&gt; lockMap = new HashMap&lt;&gt;();
        
        for (RowLock rowLock : rowLocks) {
            lockMap.computeIfAbsent(
                rowLock.getTableName(), 
                k -&gt; new HashSet&lt;&gt;()
            ).add(rowLock.getPk());
        }
        
        return lockMap.entrySet().stream()
            .map(e -&gt; e.getKey() + ":" + String.join(",", e.getValue()))
            .collect(Collectors.joining(";"));
    }
}</code></pre><h2>五、性能优化配置</h2>
<pre><code class="language-yaml">seata:
  client:
    rm:
      # 异步提交优化
      async-commit-buffer-limit: 10000
      
    undo:
      # 只记录更新字段
      only-care-update-columns: true
      
      # 压缩 undo_log
      compress:
        enable: true
        type: zip
        threshold: 64k</code></pre><h2>六、常见问题</h2>
<ol>
<li><strong>脏写问题</strong>：通过全局锁和数据校验解决</li>
<li><strong>性能问题</strong>：使用异步提交、undo_log 压缩</li>
<li><strong>undo_log 膨胀</strong>：定期清理历史数据</li>
</ol>
<pre><code class="language-sql">-- 清理 7 天前的 undo_log
DELETE FROM undo_log 
WHERE log_created &lt; DATE_SUB(NOW(), INTERVAL 7 DAY);</code></pre><p>AT 模式通过自动生成前后镜像和反向 SQL，实现了对业务代码零侵入的分布式事务解决方案，是 Seata 最推荐使用的模式。​​​​​​​​​​​​​​​​</p>

      <p style='text-align: right'>
      <a href='https://liuyaowen.cn/posts/default/20251217#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">6942a25026b72f4e551588c5</guid>
  <category>posts</category>
<category>技术</category>
 </item>
  <item>
    <title>Koupleless 合并部署完整教程</title>
    <link>https://liuyaowen.cn/posts/default/20251216</link>
    <pubDate>Tue, 16 Dec 2025 13:10:56 GMT</pubDate>
    <description>目录

Koupleless 简介
合并部署概述
环境准备
基座改造
模块改造
模块瘦身
部署与验证</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://liuyaowen.cn/posts/default/20251216'>https://liuyaowen.cn/posts/default/20251216</a></blockquote>
      <h2>目录</h2>
<ol>
<li><a href="#koupleless-%E7%AE%80%E4%BB%8B">Koupleless 简介</a></li>
<li><a href="#%E5%90%88%E5%B9%B6%E9%83%A8%E7%BD%B2%E6%A6%82%E8%BF%B0">合并部署概述</a></li>
<li><a href="#%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87">环境准备</a></li>
<li><a href="#%E5%9F%BA%E5%BA%A7%E6%94%B9%E9%80%A0">基座改造</a></li>
<li><a href="#%E6%A8%A1%E5%9D%97%E6%94%B9%E9%80%A0">模块改造</a></li>
<li><a href="#%E6%A8%A1%E5%9D%97%E7%98%A6%E8%BA%AB">模块瘦身</a></li>
<li><a href="#%E9%83%A8%E7%BD%B2%E4%B8%8E%E9%AA%8C%E8%AF%81">部署与验证</a></li>
<li><a href="#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E4%B8%8E%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88">常见问题与解决方案</a></li>
<li><a href="#%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5">最佳实践</a></li>
<li><a href="#%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E9%83%A8%E7%BD%B2">生产环境部署</a></li>
</ol>
<hr>
<h2>Koupleless 简介</h2>
<p>Koupleless 是一种模块化的 Serverless 技术解决方案，它能让普通应用以较低的代价演进为 Serverless 研发模式，让代码与资源解耦，轻松独立维护，同时支持秒级构建部署、合并部署、动态伸缩等能力，为用户提供极致的研发运维体验。</p>
<h3>适用场景</h3>
<ol>
<li><strong>应用构建发布慢或 SDK 升级繁琐</strong>：传统应用构建发布需要 6-10 分钟，使用 Koupless 可降至 10 秒级</li>
<li><strong>长尾应用资源浪费</strong>：企业 80% 的应用 CPU 使用率低于 10%，合并部署可显著降低资源成本</li>
<li><strong>研发协作效率低</strong>：多人开发一个应用时，需要统一时间窗口发布， Koupless 支持模块独立迭代</li>
<li><strong>中台应用难以沉淀业务资产</strong>：通过基座沉淀公共能力，模块实现具体业务逻辑</li>
<li><strong>微服务演进成本高</strong>：支持应用在单体、模块化、微服务架构间平滑过渡</li>
</ol>
<hr>
<h2>合并部署概述</h2>
<h3>什么是合并部署</h3>
<p>合并部署是指将多个独立的应用（在 Koupless 中称为&quot;模块&quot;）部署到同一个 JVM 进程中，共享基座的资源和依赖，但保持代码和运行时的隔离。</p>
<h3>合并部署的优势</h3>
<ul>
<li><strong>节省资源</strong>：多个模块共享基座的内存（Metaspace 和 Heap），CPU 使用率有效提升</li>
<li><strong>快速启动</strong>：模块构建产物从数百 MB 瘦身到几十 MB，启动时间大幅缩短</li>
<li><strong>简化运维</strong>：统一管理多个模块，降低维护成本</li>
<li><strong>灵活扩展</strong>：支持动态添加、移除模块，无需重启基座</li>
</ul>
<h3>架构原理</h3>
<p>Koupleless 基于 SOFAArk 框架实现类隔离和合并部署：</p>
<ul>
<li><strong>基座（Base）</strong>：提供公共依赖和基础能力，如 SpringBoot 框架、中间件 SDK 等</li>
<li><strong>模块（Biz）</strong>：业务功能模块，依赖基座提供的公共能力</li>
<li><strong>类加载机制</strong>：模块优先从自己的 ClassLoader 查找类，找不到再委托给基座 ClassLoader</li>
</ul>
<hr>
<h2>环境准备</h2>
<h3>前置条件</h3>
<ul>
<li>JDK 8 或更高版本</li>
<li>Maven 3.6+</li>
<li>SpringBoot 2.x 或 3.x</li>
<li>可用的 IDE（IntelliJ IDEA、Eclipse 等）</li>
</ul>
<h3>版本选择</h3>
<p>当前 Koupleless 主要版本：</p>
<pre><code class="language-xml">&lt;koupleless.runtime.version&gt;0.5.6&lt;/koupleless.runtime.version&gt;
&lt;sofa.ark.version&gt;2.2.14&lt;/sofa.ark.version&gt;</code></pre><h3>工具准备</h3>
<p>下载 Arkctl 工具（用于模块部署）：</p>
<pre><code class="language-bash"># Mac/Linux
wget https://github.com/koupleless/koupleless/releases/download/arkctl-release-0.2.0/arkctl-darwin-amd64
mv arkctl-darwin-amd64 /usr/local/bin/arkctl
chmod +x /usr/local/bin/arkctl

# Linux
wget https://github.com/koupleless/koupleless/releases/download/arkctl-release-0.2.0/arkctl-linux-amd64
mv arkctl-linux-amd64 /usr/local/bin/arkctl
chmod +x /usr/local/bin/arkctl</code></pre><hr>
<h2>基座改造</h2>
<h3>1. 添加依赖</h3>
<p>在基座的 <code>pom.xml</code> 中添加 Koupleless 基座依赖：</p>
<pre><code class="language-xml">&lt;properties&gt;
    &lt;koupleless.runtime.version&gt;0.5.6&lt;/koupleless.runtime.version&gt;
    &lt;sofa.ark.version&gt;2.2.14&lt;/sofa.ark.version&gt;
&lt;/properties&gt;

&lt;dependencies&gt;
    
    &lt;dependency&gt;
        &lt;groupId&gt;com.alipay.sofa.koupleless&lt;/groupId&gt;
        &lt;artifactId&gt;koupleless-base-starter&lt;/artifactId&gt;
        &lt;version&gt;${koupleless.runtime.version}&lt;/version&gt;
    &lt;/dependency&gt;
    
    
    &lt;dependency&gt;
        &lt;groupId&gt;com.alipay.sofa&lt;/groupId&gt;
        &lt;artifactId&gt;web-ark-plugin&lt;/artifactId&gt;
    &lt;/dependency&gt;
&lt;/dependencies&gt;</code></pre><h3>2. 配置应用名</h3>
<p>在 <code>application.properties</code> 或 <code>application.yml</code> 中配置应用名：</p>
<pre><code class="language-properties"># application.properties
spring.application.name=base-application</code></pre><h3>3. 配置基座构建插件</h3>
<p>在 <code>pom.xml</code> 的 <code>build</code> 部分添加基座构建插件：</p>
<pre><code class="language-xml">&lt;build&gt;
    &lt;plugins&gt;
        
        &lt;plugin&gt;
            &lt;groupId&gt;com.alipay.sofa.koupleless&lt;/groupId&gt;
            &lt;artifactId&gt;koupleless-base-build-plugin&lt;/artifactId&gt;
            &lt;version&gt;${koupleless.runtime.version}&lt;/version&gt;
            &lt;executions&gt;
                &lt;execution&gt;
                    &lt;goals&gt;
                        &lt;goal&gt;add-patch&lt;/goal&gt;
                    &lt;/goals&gt;
                &lt;/execution&gt;
            &lt;/executions&gt;
            &lt;configuration&gt;
                
                &lt;dependencyArtifactId&gt;${baseAppName}-dependencies-starter&lt;/dependencyArtifactId&gt;
                
                &lt;dependencyVersion&gt;0.0.1-SNAPSHOT&lt;/dependencyVersion&gt;
            &lt;/configuration&gt;
        &lt;/plugin&gt;
        
        
        &lt;plugin&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;
            &lt;executions&gt;
                &lt;execution&gt;
                    &lt;goals&gt;
                        &lt;goal&gt;repackage&lt;/goal&gt;
                    &lt;/goals&gt;
                &lt;/execution&gt;
            &lt;/executions&gt;
        &lt;/plugin&gt;
    &lt;/plugins&gt;
&lt;/build&gt;</code></pre><h3>4. 生成依赖 Starter</h3>
<p>执行以下命令生成基座的依赖 starter：</p>
<pre><code class="language-bash">mvn com.alipay.sofa.koupleless:koupleless-base-build-plugin::packageDependency -f pom.xml</code></pre><p>这个命令会生成一个 <code>${baseAppName}-dependencies-starter</code> 的依赖包，模块项目可以将其作为 parent 来继承基座的所有依赖。</p>
<h3>5. 启动基座</h3>
<p>完成上述配置后，可以直接通过 IDE 或命令行启动基座：</p>
<pre><code class="language-bash">mvn spring-boot:run</code></pre><hr>
<h2>模块改造</h2>
<h3>1. 创建模块项目</h3>
<p>模块可以是现有的 SpringBoot 应用，也可以是新创建的项目。建议使用 Maven 多模块结构管理多个模块。</p>
<h3>2. 配置模块依赖</h3>
<p>在模块的 <code>pom.xml</code> 中添加模块依赖和打包插件：</p>
<pre><code class="language-xml">&lt;properties&gt;
    &lt;koupleless.runtime.version&gt;0.5.6&lt;/koupleless.runtime.version&gt;
    &lt;sofa.ark.version&gt;2.2.14&lt;/sofa.ark.version&gt;
&lt;/properties&gt;

&lt;dependencies&gt;
    
    &lt;dependency&gt;
        &lt;groupId&gt;com.alipay.sofa.koupleless&lt;/groupId&gt;
        &lt;artifactId&gt;koupleless-app-starter&lt;/artifactId&gt;
        &lt;version&gt;${koupleless.runtime.version}&lt;/version&gt;
        &lt;scope&gt;provided&lt;/scope&gt;
    &lt;/dependency&gt;
    
    
    &lt;parent&gt;
        &lt;groupId&gt;com.example&lt;/groupId&gt;
        &lt;artifactId&gt;base-application-dependencies-starter&lt;/artifactId&gt;
        &lt;version&gt;0.0.1-SNAPSHOT&lt;/version&gt;
    &lt;/parent&gt;
&lt;/dependencies&gt;</code></pre><h3>3. 配置打包插件</h3>
<p>在模块的 <code>pom.xml</code> 中添加 SOFAArk 打包插件：</p>
<pre><code class="language-xml">&lt;build&gt;
    &lt;finalName&gt;${project.artifactId}&lt;/finalName&gt;
    &lt;plugins&gt;
        
        &lt;plugin&gt;
            &lt;groupId&gt;com.alipay.sofa&lt;/groupId&gt;
            &lt;artifactId&gt;sofa-ark-maven-plugin&lt;/artifactId&gt;
            &lt;version&gt;${sofa.ark.version}&lt;/version&gt;
            &lt;executions&gt;
                &lt;execution&gt;
                    &lt;id&gt;default-cli&lt;/id&gt;
                    &lt;goals&gt;
                        &lt;goal&gt;repackage&lt;/goal&gt;
                    &lt;/goals&gt;
                &lt;/execution&gt;
            &lt;/executions&gt;
            &lt;configuration&gt;
                
                &lt;skipArkExecutable&gt;true&lt;/skipArkExecutable&gt;
                
                &lt;outputDirectory&gt;./target&lt;/outputDirectory&gt;
                
                &lt;bizName&gt;module1&lt;/bizName&gt;
                
                &lt;webContextPath&gt;/module1&lt;/webContextPath&gt;
                
                &lt;declaredMode&gt;true&lt;/declaredMode&gt;
                
                &lt;packExcludesConfig&gt;rules.txt&lt;/packExcludesConfig&gt;
            &lt;/configuration&gt;
        &lt;/plugin&gt;
        
        
        &lt;plugin&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;
            &lt;executions&gt;
                &lt;execution&gt;
                    &lt;goals&gt;
                        &lt;goal&gt;repackage&lt;/goal&gt;
                    &lt;/goals&gt;
                &lt;/execution&gt;
            &lt;/executions&gt;
        &lt;/plugin&gt;
    &lt;/plugins&gt;
&lt;/build&gt;</code></pre><h3>4. 配置模块瘦身</h3>
<p>下载自动排包配置文件 <code>rules.txt</code>，放在模块的 <code>conf/ark/</code> 目录下：</p>
<pre><code class="language-bash"># 创建目录
mkdir -p conf/ark

# 下载配置文件
wget https://github.com/koupleless/samples/raw/master/springboot-samples/slimming/log4j2/biz1/conf/ark/rules.txt -O conf/ark/rules.txt</code></pre><p><code>rules.txt</code> 内容示例：</p>
<pre><code class="language-"># 排除的依赖（groupId:artifactId:version）
excludes=org.springframework.boot:spring-boot-starter-logging,commons-logging:commons-logging

# 排除的 groupId
excludeGroupIds=org.slf4j

# 排除的 artifactId
excludeArtifactIds=logback-classic</code></pre><h3>5. 开发模块代码</h3>
<p>创建模块的业务代码，例如 REST Controller：</p>
<pre><code class="language-java">package com.example.module1.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.context.ApplicationContext;

@RestController
@RequestMapping("/")
public class SampleController {
    
    private static final Logger LOGGER = LoggerFactory.getLogger(SampleController.class);
    
    @Autowired
    private ApplicationContext applicationContext;
    
    @GetMapping("/")
    public String hello() {
        String appName = applicationContext.getApplicationName();
        LOGGER.info("{} web test: into sample controller", appName);
        return String.format("hello to %s deploy", appName);
    }
    
    @GetMapping("/health")
    public String health() {
        return "OK";
    }
}</code></pre><h3>6. 配置应用名</h3>
<p>在模块的 <code>application.properties</code> 中配置应用名：</p>
<pre><code class="language-properties">spring.application.name=module1</code></pre><hr>
<h2>模块瘦身</h2>
<h3>为什么要模块瘦身</h3>
<p>模块瘦身可以：</p>
<ul>
<li><strong>减小模块包大小</strong>：从数百 MB 降低到几十 MB</li>
<li><strong>减少内存占用</strong>：降低 Metaspace 占用，基座可以安装更多模块</li>
<li><strong>加快启动速度</strong>：模块启动时间从分钟级降低到秒级</li>
<li><strong>提高部署效率</strong>：小体积包更容易传输和部署</li>
</ul>
<h3>瘦身原理</h3>
<p>模块瘦身的核心思想是：<strong>移除模块中与基座重复的依赖，让模块复用基座的类</strong></p>
<p>通过以下方式实现：</p>
<ol>
<li><strong>继承基座依赖 starter</strong>：模块以 <code>${baseAppName}-dependencies-starter</code> 作为 parent</li>
<li><strong>自动排包</strong>：使用 <code>rules.txt</code> 配置文件排除不需要的依赖</li>
<li><strong>依赖委托</strong>：模块依赖设置为 <code>provided</code> 作用域，委托给基座加载</li>
</ol>
<h3>配置方法</h3>
<h4>方法一：继承基座依赖 starter（推荐）</h4>
<p>在模块的 <code>pom.xml</code> 中配置：</p>
<pre><code class="language-xml">&lt;parent&gt;
    &lt;groupId&gt;com.example&lt;/groupId&gt;
    &lt;artifactId&gt;base-application-dependencies-starter&lt;/artifactId&gt;
    &lt;version&gt;0.0.1-SNAPSHOT&lt;/version&gt;
&lt;/parent&gt;</code></pre><p>这样模块会自动继承基座的所有依赖，无需手动配置版本。</p>
<h4>方法二：手动配置依赖作用域</h4>
<p>在模块的 <code>pom.xml</code> 中，将基座已有的依赖设置为 <code>provided</code> 作用域：</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
    &lt;scope&gt;provided&lt;/scope&gt;
&lt;/dependency&gt;</code></pre><h4>方法三：使用排除配置</h4>
<p>在 <code>sofa-ark-maven-plugin</code> 中配置排除项：</p>
<pre><code class="language-xml">&lt;configuration&gt;
    &lt;excludeGroupIds&gt;org.springframework,org.slf4j&lt;/excludeGroupIds&gt;
    &lt;excludeArtifactIds&gt;spring-boot-starter-logging&lt;/excludeArtifactIds&gt;
&lt;/configuration&gt;</code></pre><h3>验证瘦身效果</h3>
<p>构建模块并查看包大小：</p>
<pre><code class="language-bash"># 构建模块
mvn clean package

# 查看构建产物
ls -lh target/*.jar

# 瘦身前
-rw-r--r--  1 user  staff   250M  1 15 10:00 module1-1.0.0.jar
-rw-r--r--  1 user  staff   180M  1 15 10:00 module1-1.0.0-ark-biz.jar

# 瘦身后
-rw-r--r--  1 user  staff   250M  1 15 10:30 module1-1.0.0.jar
-rw-r--r--  1 user  staff    15M  1 15 10:30 module1-1.0.0-ark-biz.jar</code></pre><p>可以看到，瘦身后的 ark-biz.jar 从 180M 降低到 15M，效果显著。</p>
<hr>
<h2>部署与验证</h2>
<h3>1. 启动基座</h3>
<p>首先启动基座应用：</p>
<pre><code class="language-bash">cd base-application
mvn spring-boot:run</code></pre><p>或使用 IDE 启动主类。</p>
<h3>2. 构建模块</h3>
<p>在模块项目中执行构建：</p>
<pre><code class="language-bash">cd module1
mvn clean package</code></pre><p>构建成功后会生成两个 jar 包：</p>
<ul>
<li><code>module1-1.0.0.jar</code>：普通 SpringBoot fat jar，可独立运行</li>
<li><code>module1-1.0.0-ark-biz.jar</code>：Koupleless 模块包，用于合并部署</li>
</ul>
<h3>3. 使用 Arkctl 部署模块</h3>
<p>使用 Arkctl 工具将模块部署到基座：</p>
<pre><code class="language-bash"># 部署模块
arkctl deploy target/module1-1.0.0-ark-biz.jar

# 或者使用 curl 命令
curl -X POST http://localhost:1238/installBiz \
  -H "Content-Type: application/json" \
  -d '{"bizName":"module1","bizVersion":"1.0.0","bizUrl":"file:///path/to/module1-1.0.0-ark-biz.jar"}'</code></pre><h3>4. 验证部署</h3>
<p>部署成功后，可以通过以下方式验证：</p>
<h4>查看模块状态</h4>
<pre><code class="language-bash">arkctl status</code></pre><p>输出示例：</p>
<pre><code class="language-">Biz count: 1
bizName: module1
bizVersion: 1.0.0
bizState: ACTIVATED
mainClass: com.example.module1.Module1Application
webContextPath: /module1</code></pre><h4>访问模块接口</h4>
<pre><code class="language-bash"># 访问模块1的接口
curl http://localhost:8080/module1/
# 输出: hello to module1 deploy

curl http://localhost:8080/module1/health
# 输出: OK</code></pre><h4>查看日志</h4>
<p>在基座控制台可以看到模块启动日志：</p>
<pre><code class="language-">[INFO] Install biz: module1, version: 1.0.0, state: ACTIVATED
[INFO] Module module1 started successfully</code></pre><h3>5. 部署多个模块</h3>
<p>重复上述步骤部署 module2：</p>
<pre><code class="language-bash">cd module2
mvn clean package
arkctl deploy target/module2-1.0.0-ark-biz.jar</code></pre><p>验证多模块部署：</p>
<pre><code class="language-bash"># 查看所有模块
arkctl status

# 访问不同模块
curl http://localhost:8080/module1/
curl http://localhost:8080/module2/</code></pre><h3>6. 卸载模块</h3>
<pre><code class="language-bash"># 卸载指定模块
arkctl uninstall --bizName=module1 --bizVersion=1.0.0

# 或者使用 curl
curl -X POST http://localhost:1238/uninstallBiz \
  -H "Content-Type: application/json" \
  -d '{"bizName":"module1","bizVersion":"1.0.0"}'</code></pre><hr>
<h2>常见问题与解决方案</h2>
<h3>1. 类冲突问题</h3>
<p><strong>问题描述</strong>：模块和基座存在相同类名的类，导致类冲突。</p>
<p><strong>解决方案</strong>：</p>
<ul>
<li>使用类隔离机制，模块优先加载自己的类</li>
<li>公共类下沉到基座，模块通过依赖委托复用</li>
<li>避免在模块和基座中定义相同包名和类名的类</li>
</ul>
<h3>2. 静态字段共享问题</h3>
<p><strong>问题描述</strong>：多个模块共享静态字段，导致数据混乱。</p>
<p><strong>解决方案</strong>：</p>
<p>使用 <code>StaticFieldMapWrapper</code> 适配静态字段：</p>
<pre><code class="language-java">import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;

public class StaticFieldMapWrapper&lt;T&gt; {
    private final ConcurrentHashMap&lt;ClassLoader, T&gt; classLoaderTMap = new ConcurrentHashMap&lt;&gt;();
    private Supplier&lt;T&gt; getDefaultMethod;
    
    public StaticFieldMapWrapper(Supplier&lt;T&gt; getDefaultMethod) {
        this.getDefaultMethod = getDefaultMethod;
    }
    
    public T getOrPutDefault() {
        T t = classLoaderTMap.get(Thread.currentThread().getContextClassLoader());
        if (t == null && getDefaultMethod != null) {
            t = getDefaultMethod.get();
            classLoaderTMap.put(Thread.currentThread().getContextClassLoader(), t);
        }
        return t;
    }
}</code></pre><h3>3. Dubbo 兼容性问题</h3>
<p><strong>问题描述</strong>：模块中使用 Dubbo 泛化调用时出现问题。</p>
<p><strong>解决方案</strong>：</p>
<ul>
<li>确保 Dubbo 相关依赖下沉到基座</li>
<li>为 Dubbo 配置正确的 ClassLoader 切换</li>
<li>参考官方 adapter 仓库的解决方案</li>
</ul>
<h3>4. Nacos 线程过多</h3>
<p><strong>问题描述</strong>：每个模块启动时创建 Nacos 客户端，导致线程数过多。</p>
<p><strong>解决方案</strong>：</p>
<p>在基座中缓存 Nacos 客户端：</p>
<pre><code class="language-java">private static final Map&lt;Properties, ConfigService&gt; CONFIG_SERVICE_MAP = new ConcurrentHashMap&lt;&gt;();

public static ConfigService getConfigService(Properties properties) {
    return CONFIG_SERVICE_MAP.computeIfAbsent(properties, k -&gt; {
        try {
            return NacosFactory.createConfigService(k);
        } catch (NacosException e) {
            throw new RuntimeException(e);
        }
    });
}</code></pre><h3>5. 模块启动顺序问题</h3>
<p><strong>问题描述</strong>：模块之间有依赖关系，需要按顺序启动。</p>
<p><strong>解决方案</strong>：</p>
<ul>
<li>在基座中配置模块启动顺序</li>
<li>使用依赖关系控制启动顺序</li>
<li>避免循环依赖</li>
</ul>
<h3>6. 内存泄漏</h3>
<p><strong>问题描述</strong>：模块卸载后，内存没有释放，导致 Metaspace 持续增长。</p>
<p><strong>解决方案</strong>：</p>
<ul>
<li>确保模块卸载时清理所有资源（线程池、连接等）</li>
<li>使用弱引用管理缓存</li>
<li>定期重启基座或进行内存整理</li>
</ul>
<h3>7. Spring 上下文冲突</h3>
<p><strong>问题描述</strong>：模块和基座的 Spring Bean 冲突。</p>
<p><strong>解决方案</strong>：</p>
<ul>
<li>使用不同的 Bean 名称</li>
<li>模块中使用 <code>@Primary</code> 注解明确优先级</li>
<li>避免模块和基座定义相同的 Bean</li>
</ul>
<hr>
<h2>生产环境部署</h2>
<h3>1. 部署架构</h3>
<p>生产环境推荐部署架构：</p>
<pre><code class="language-">用户流量 -&gt; Nginx/SLB -&gt; 基座应用（K8s Pod）
                                    |
                                    +-&gt; 模块1
                                    +-&gt; 模块2
                                    +-&gt; 模块3</code></pre><h3>2. K8s 部署配置</h3>
<h4>基座 Deployment</h4>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: base-application
spec:
  replicas: 3
  selector:
    matchLabels:
      app: base-application
  template:
    metadata:
      labels:
        app: base-application
    spec:
      containers:
      - name: base-application
        image: registry.example.com/base-application:1.0.0
        ports:
        - containerPort: 8080
        env:
        - name: MODULE_AUTO_INSTALL
          value: "true"
        - name: MODULE_OSS_ENDPOINT
          value: "oss-cn-region.aliyuncs.com"
        - name: MODULE_OSS_BUCKET
          value: "koupleless-modules"
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 5
        volumeMounts:
        - name: module-storage
          mountPath: /opt/modules
      volumes:
      - name: module-storage
        persistentVolumeClaim:
          claimName: module-pvc</code></pre><h4>模块发布流水线</h4>
<pre><code class="language-yaml"># 模块 CI/CD Pipeline
stages:
  - build
  - test
  - package
  - deploy

build_module:
  stage: build
  script:
    - mvn clean compile

test_module:
  stage: test
  script:
    - mvn test

package_module:
  stage: package
  script:
    - mvn clean package
    - cp target/*.ark-biz.jar module.ark-biz.jar
  artifacts:
    paths:
      - module.ark-biz.jar

deploy_module:
  stage: deploy
  script:
    # 上传模块到 OSS
    - ossutil cp module.ark-biz.jar oss://koupleless-modules/module1/${CI_COMMIT_SHA}.ark-biz.jar
    
    # 通知基座更新模块
    - curl -X POST http://base-service:8080/install \
        -H "Content-Type: application/json" \
        -d '{"module":"module1","version":"'${CI_COMMIT_SHA}'"}'</code></pre><h3>3. 模块动态部署</h3>
<h4>方案一：OSS 监听模式</h4>
<p>基座监听 OSS 目录变化，自动部署新模块：</p>
<pre><code class="language-java">@Service
public class ModuleDeploymentService {
    
    @Autowired
    private OssClient ossClient;
    
    @Scheduled(fixedDelay = 5000)
    public void checkModuleUpdates() {
        // 检查 OSS 上的模块版本
        List&lt;String&gt; modules = getModuleList();
        
        for (String module : modules) {
            String latestVersion = getLatestVersion(module);
            String currentVersion = getCurrentVersion(module);
            
            if (!latestVersion.equals(currentVersion)) {
                // 下载新模块
                downloadModule(module, latestVersion);
                
                // 卸载旧模块
                uninstallModule(module, currentVersion);
                
                // 安装新模块
                installModule(module, latestVersion);
            }
        }
    }
}</code></pre><h4>方案二：配置中心模式</h4>
<p>通过配置中心控制模块部署：</p>
<pre><code class="language-yaml"># 配置中心配置
modules:
  module1:
    version: 1.0.0
    enabled: true
    healthCheck: /health
  module2:
    version: 2.0.0
    enabled: true
    healthCheck: /health</code></pre><p>基座监听配置变化：</p>
<pre><code class="language-java">@NacosConfigListener(dataId = "modules.json")
public void onModuleConfigChange(String config) {
    ModulesConfig modulesConfig = JSON.parseObject(config, ModulesConfig.class);
    
    // 对比配置差异
    List&lt;ModuleChange&gt; changes = compareModuleConfig(modulesConfig);
    
    // 应用变更
    for (ModuleChange change : changes) {
        switch (change.getType()) {
            case INSTALL:
                installModule(change.getModule());
                break;
            case UNINSTALL:
                uninstallModule(change.getModule());
                break;
            case UPDATE:
                updateModule(change.getModule());
                break;
        }
    }
}</code></pre><p>下面把教程里没展开的两件事一次讲透：  </p>
<ol>
<li>静态合并部署（Static Packaged Deployment）——一次性把基座 + N 个模块打成一个可执行 Fat Jar，启动即“全员就位”；  </li>
<li>端口隔离——让每个模块真的监听自己的端口，实现“进程内多 WebServer”。</li>
</ol>
<hr>
<h4>方案三、静态合并部署（Static Packaged Deployment）</h4>
<p><strong>适用场景</strong>  </p>
<ul>
<li>上线流程极简，不允许运维侧再敲 arkctl deploy；  </li>
<li>容器镜像只启一个进程，K8s liveness/readiness 探针直接探测基座即可；  </li>
<li>模块版本固定，不需要热卸载/热加载。</li>
</ul>
<ol>
<li><p><strong>打包思路</strong><br>基座 pom 里把各模块的 ark-biz.jar 当资源一起打进 BOOT-INF/lib 目录；<br>基座启动时借助 SOFAArk 的 “static-biz” 机制，让这些 ark-biz.jar 随基座生命周期一起 install + start。</p>
</li>
<li><p><strong>实操步骤</strong><br>step-1 模块端正常 mvn package 得到 moduleX-1.0.0-ark-biz.jar<br>step-2 基座 pom 增加 profile（只在打静态包时激活）</p>
</li>
</ol>
<pre><code class="language-xml">
&lt;profiles&gt;
  &lt;profile&gt;
    &lt;id&gt;static-pack&lt;/id&gt;
    &lt;dependencies&gt;
      
      &lt;dependency&gt;
        &lt;groupId&gt;com.example&lt;/groupId&gt;
        &lt;artifactId&gt;module1&lt;/artifactId&gt;
        &lt;version&gt;1.0.0&lt;/version&gt;
        &lt;classifier&gt;ark-biz&lt;/classifier&gt;
        &lt;type&gt;jar&lt;/type&gt;
      &lt;/dependency&gt;
      &lt;dependency&gt;
        &lt;groupId&gt;com.example&lt;/groupId&gt;
        &lt;artifactId&gt;module2&lt;/artifactId&gt;
        &lt;version&gt;1.0.0&lt;/version&gt;
        &lt;classifier&gt;ark-biz&lt;/classifier&gt;
        &lt;type&gt;jar&lt;/type&gt;
      &lt;/dependency&gt;
    &lt;/dependencies&gt;
  &lt;/profile&gt;
&lt;/profiles&gt;</code></pre><p>step-3 基座启动类加 <code>@StaticBiz</code> 注解（0.5.6+ 支持），告诉 SOFAArk 把 classpath 下所有 ark-biz.jar 静态安装：</p>
<pre><code class="language-java">@SpringBootApplication
@StaticBiz   // 关键
public class BaseApplication {
    public static void main(String[] args) {
        SpringApplication.run(BaseApplication.class, args);
    }
}</code></pre><p>step-4 打静态包</p>
<pre><code class="language-bash">mvn clean package -Pstatic-pack</code></pre><p>生成的 base-application-1.0.0-exec.jar 里同时包含 module1-ark-biz.jar &amp; module2-ark-biz.jar；<br>java -jar base-application-1.0.0-exec.jar 启动后，arkctl status 能看到两个模块已经是 ACTIVATED，无需再 deploy。</p>
<p><strong>让模块监听自己的端口</strong></p>
<p>默认行为  </p>
<ul>
<li>所有模块与基座共用同一个嵌入式 Tomcat（端口 8080），仅靠 webContextPath 区分；  </li>
<li>好处：节省线程、节省内存；  </li>
<li>坏处：无法“端口级”隔离，也做不到“模块单独暴露管理口”。</li>
</ul>
<p>Koupleless 0.5.6 开始支持 “多 WebServer 模式”，即每个模块可以起独立的 Netty/Tomcat，真正 bind 自己的端口。</p>
<ol>
<li>模块端增加依赖</li>
</ol>
<pre><code class="language-xml">
&lt;dependency&gt;
    &lt;groupId&gt;com.alipay.sofa&lt;/groupId&gt;
    &lt;artifactId&gt;web-ark-plugin&lt;/artifactId&gt;
    &lt;classifier&gt;multi-web&lt;/classifier&gt;   
    &lt;scope&gt;provided&lt;/scope&gt;
&lt;/dependency&gt;</code></pre><ol start="2">
<li>配置端口 &amp; 上下文</li>
</ol>
<pre><code class="language-yaml"># module1/application.yml
server:
  port: 8081          # 模块独占
  servlet:
    context-path: /   # 可以为 /
spring:
  application:
    name: module1</code></pre><ol start="3">
<li>打包插件声明</li>
</ol>
<pre><code class="language-xml">&lt;plugin&gt;
  &lt;groupId&gt;com.alipay.sofa&lt;/groupId&gt;
  &lt;artifactId&gt;sofa-ark-maven-plugin&lt;/artifactId&gt;
  &lt;configuration&gt;
    &lt;bizName&gt;module1&lt;/bizName&gt;
    &lt;webContextPath&gt;/&lt;/webContextPath&gt;
    
    &lt;webMultiFlag&gt;true&lt;/webMultiFlag&gt;
  &lt;/configuration&gt;
&lt;/plugin&gt;</code></pre><ol start="4">
<li>部署验证<br>基座仍跑 8080，module1 会额外监听 8081，module2 可再配 8082，互不影响；<br>curl <a href="http://localhost:8081/">http://localhost:8081/</a> 直接返回模块 1 的响应，无需再加前缀。</li>
</ol>
<p>注意  </p>
<ul>
<li>端口多占 n 个，内存/线程也会相应增加，按需权衡；  </li>
<li>生产环境需在 K8s Service 里把 8081、8082… 也声明出来，或走 Istio 多端口即可。</li>
</ul>
<h2>参考资源</h2>
<ul>
<li><strong>官方文档</strong>：<a href="https://koupleless.io/docs/">https://koupleless.io/docs/</a></li>
<li><strong>GitHub 仓库</strong>：<a href="https://github.com/koupleless/koupleless">https://github.com/koupleless/koupleless</a></li>
<li><strong>示例代码</strong>：<a href="https://github.com/koupleless/samples">https://github.com/koupleless/samples</a></li>
<li><strong>社区支持</strong>：<a href="https://github.com/koupleless/koupleless/issues">https://github.com/koupleless/koupleless/issues</a></li>
</ul>

      <p style='text-align: right'>
      <a href='https://liuyaowen.cn/posts/default/20251216#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69415a6026b72f4e55156fc3</guid>
  <category>posts</category>
<category>技术</category>
 </item>
  
</channel>
</rss>